Cordis, Level Up

Services: dependencies with ctx.get / inject

A service is a named capability provided or consumed through ctxctx.tools, ctx.llm and ctx.agents are all services. Consumers declare capabilities by name ('tools') instead of importing providers, so swapping a provider needs no consumer changes.

Hard dependencies use inject: the plugin stays PENDING until every listed service is ready, guaranteed inside apply; if a dependency disappears at runtime (provider replaced or unloaded), the plugin unloads and reloads when the service returns:

export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(/* ... */)
}

Optional dependencies use ctx.get: skip inject and probe at the use site; the plugin keeps running when the service is missing:

export function apply(ctx: Context) {
  const greeter = ctx.get('greeter')   // undefined when not provided
  console.log(greeter?.greet('maybe') ?? 'no greeter available')
}

Providing a service: extend the Service base class and call super(ctx, 'myService') in the constructor; ctx.provide(name, value) also registers an implementation owned by the current fiber. Service names share one flat namespace — prefix your own names to avoid colliding with taken ones like tools and llm.

Events: listen with ctx.on, five dispatch modes

Events let plugins notify without knowing who is listening. ctx.on(name, listener) registers a listener and is itself an effect — removed on unload, no manual removeListener:

export function apply(ctx: Context) {
  ctx.on('stats/report', (name, count) => {
    console.log(`[stats] ${name} -> ${count}`)
  })
}

Different events use different dispatch modes; whether listeners can return values, run concurrently, or short-circuit depends on the mode:

ModeCallSemantics
emitctx.emit(name, ...args)synchronous broadcast, return values ignored
parallelawait ctx.parallel(name, ...args)all listeners run concurrently and are awaited together
serialawait ctx.serial(name, ...args)awaited in order; the first non-null/false/undefined return wins
bailctx.bail(name, ...args)the synchronous version of serial
waterfallctx.waterfall(name, ...args, next)wrap-around middleware; transform or short-circuit

Waterfall is the interception pattern: each listener receives a next() continuation — call it to run downstream listeners, or return directly to short-circuit. The discipline: waterfall listeners that only observe or annotate must call next(), or they silently swallow downstream default behavior.

Slots: UI for the Web GUI

Beyond host-side capabilities, browser-side plugins can register UI in the Web GUI. The GUI abstracts its extendable regions as Slots (e.g. sidebars, message cards, settings panels), and client plugins register React components into a Slot to render their UI. The extension tooling offers read-only queries (such as Slots.listSubTree) to browse the available Slot tree and its props contract; at the code level, plugins pass JSON between browser and host through package-private methods like host.call / harness.handle. Write logic on the Host, UI on the Client, and connect them via Slots and those private methods.

Effects: lifecycle cleanup

Registrations managed by Cordis are revoked on unload; resources you manage yourself (timers, connections, watchers) must be wrapped in ctx.effect() returning a disposer:

export function apply(ctx: Context) {
  ctx.effect(() => {
    const timer = setInterval(() => console.log('tick'), 200)
    return () => clearInterval(timer)   // runs on unload
  })
}

Already-effectful operations include ctx.on() listeners, child plugins mounted with ctx.plugin(child), service registrations and registry calls like ctx.tools.register() — none need manual cleanup. A fiber (plugin runtime instance) walks PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED, or FAILED when apply throws. Two caveats: disposers run in reverse registration order and async disposers run concurrently; if teardown steps must be sequential, put them in one disposer and await each step inside it.