Cordis, Level Up
Services: dependencies with ctx.get / inject
A service is a named capability provided or consumed through ctx — ctx.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:
| Mode | Call | Semantics |
|---|---|---|
| emit | ctx.emit(name, ...args) | synchronous broadcast, return values ignored |
| parallel | await ctx.parallel(name, ...args) | all listeners run concurrently and are awaited together |
| serial | await ctx.serial(name, ...args) | awaited in order; the first non-null/false/undefined return wins |
| bail | ctx.bail(name, ...args) | the synchronous version of serial |
| waterfall | ctx.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.