What Cordis Is
Cordis is a meta-framework. Its job is not to be a framework for a specific domain, but to lay down the runtime rules that let independent components be composed, dismantled, and recomposed at runtime.
mount a component and expose capabilities
declare dependencies and react when they appear / disappear / change
register side effects, then reverse them on unload
reload components and update configuration
reconcile a declarative plugin tree
hot-reload only affected components
- Not a web framework
- Not a UI framework
- Not an agent framework
- Not an application framework
- Composition runtime
- Reactive dependency wiring
- Reversible side effects
- Lifecycle & reload semantics
- Declarative tree reconciliation
An application is a set of replaceable plugins, not one privileged core with many hard imports. The core owns the runtime model; the loader owns declarative composition; almost everything else is itself a plugin or service built on that model.
The Mental Model: Spatiotemporal Composability
The Cordis paper frames dynamic composition along two orthogonal axes — temporal and spatial. Together they make safe, runtime composition possible.
Can it be removed leaving nothing behind?
Every side effect must have an inverse. Cordis tracks the inverse and runs cleanup when the owning plugin/fiber unloads.
register listener→ unregisteropen socket→ closestart interval→ clearregister tool→ unregisterprovide service→ removemount child plugin→ dispose
Can it declare needs without manual ordering?
Consumers declare requirements via inject; Cordis waits until the capability exists — and this is reactive, not a one-shot boot dance.
export const inject = ['database']
If a provider disappears, the dependent unloads, its effects revert, and when a new provider appears, it loads again.
Ctrl/Cmd + wheel to zoom. Scroll to pan. Drag to pan when zoomed. Double-click to fit.
Temporal says: remove a component → undo everything it did. Spatial says: a dependency changed → reevaluate affected components. Together: safe dynamic composition.
This is the central idea behind Cordis.Architecture at a Glance
The runtime is layered: Context is the shared capability surface; a Fiber represents one mounted plugin instance; the Loader manages the declarative plugin tree — with optional support packages built on top.
Ctrl/Cmd + wheel to zoom. Scroll to pan. Drag to pan when zoomed. Double-click to fit.
The core owns the runtime model. The loader owns declarative composition. Everything else is itself a plugin or service built on that model.
The Core Building Blocks
Six primitives recur across every Cordis application. Master these and everything else is a composition of them.
ctxThe shared capability surface
Almost all interaction goes through ctx. It is simultaneously a dependency container, a service locator, a plugin owner scope, an event bus, an effect owner, and a runtime introspection surface.
ctx.plugin · ctx.effect
ctx.get · ctx.provide · ctx.set
ctx.on · ctx.emit · ctx.waterfall
fiber · registry · logger
Do not treat ctx as a bag of arbitrary global mutable state. Services own capabilities, events represent observation/interception, effects own resource lifetime.
Named capability on ctx
Two distinct steps: type declaration merging (compile-time only) and runtime registration via Service or ctx.provide(). Both are required.
Declared required dependency
export const inject = ['greeter']. Cordis guarantees declared dependencies exist before apply() runs. Missing deps leave the plugin PENDING, not partially started.
A reversible side effect
ctx.effect(() => { acquire(); return () => release(); }). The body acquires, the return value disposes. Cordis tracks the disposer.
One mounted plugin instance
ctx.plugin() returns a fiber owning config, dependency snapshot, lifecycle state, effects, child plugins, and cleanup.
Typed loose coupling
One plugin emits, zero-to-many observe. Listeners are lifecycle-owned and disappear on unload — no plugin imports another's listener.
ctx.plugin(...) · ctx.effect(...) ctx.get/provide/set/mixin(...) ctx.on/emit/parallel/serial ctx.bail/waterfall(...) ctx.fiber · registry · logger · reflect
Lifecycle: Fibers, Effects & Disposers
The fiber is the lifecycle unit. The effect/disposer pair is the operational heart of Cordis — it is what makes hot reload and safe dismantling possible.
Ctrl/Cmd + wheel to zoom. Scroll to pan. Drag to pan when zoomed. Double-click to fit.
When you write one of these…
subscribe · listen · open · start · watch · register · attach · patch
…ask: what is the inverse?
Put acquisition and inverse together inside one ctx.effect().
ctx.effect(() => { const socket = connect() socket.on('message', onMessage) return async () => { socket.off('message', onMessage) await socket.close() } })
return () => watcher.close()return () => process.off('SIGTERM', h)
LIFO — reverse registration
Disposers run in reverse registration order. Keep strict sequences inside one disposer rather than relying on independent async disposers.
ctx.effect(() => { const a = openA() const b = openB() return async () => { await closeB(b) await closeA(a) } }, 'agent-session-stream')
- setup A → setup B → setup C
- unload: cleanup C → B → A
- Labels help
fiber.getEffects()diagnostics
Don't over-wrap. Registration APIs (ctx.on, ctx.plugin, ctx.provide) already participate in lifecycle ownership.
Events & the Five Dispatch Modes
Use events when one plugin should not know its concrete consumers. Declare types, listen, emit — and pick the right dispatch contract.
agent/steptool/resultstats/reportsession/updatedpolicy/check
Avoid generic names like update, change, done. The event namespace is flat — names should communicate ownership.
declare module 'cordis' { interface Events { 'stats/report'(name: string, count: number): void } } ctx.on('stats/report', (name, count) => { /* ... */ }) ctx.emit('stats/report', 'tool_call', 3)
Declarations are compile-time; ctx.on / ctx.emit are runtime. Listeners are lifecycle-owned.
| Mode | Type | Contract | Use when |
|---|---|---|---|
emit | sync | Broadcast; everyone may observe, nobody controls the result | Observation / notification |
parallel | async | Listeners run concurrently; no order dependency | Fan-out async work |
serial | async | Listeners run in order; first meaningful result may stop later ones | Ordered async decision chains |
bail | sync | First listener that answers wins | Short-circuit sync lookup |
waterfall | intercept | Listeners receive next(); can observe, transform, wrap, veto, replace | Policy / middleware surfaces |
Listeners wrap, transform, or veto downstream behavior
ctx.on('demo/transform', async (input, next) => { const result = await next() return result.toUpperCase() // wrap / transform }) ctx.on('demo/transform', async (input, next) => { if (input === 'forbidden') return 'BLOCKED' return next() // short-circuit / veto })
Use waterfall when multiple plugins need to intercept a decision without hardwiring themselves to each other: policy A → next → policy B → next → final handler.
Configuration, Loader & Declarative Composition
The loader turns a declarative entry tree into live, mounted plugins — and reconciles changes. This is where Cordis becomes practical for larger applications.
Fail fast, before side effects
Define a Standard Schema (Schemastery) config validator. Reject invalid input before acquiring resources.
export const Config = Schema.object({ greeting: Schema.string().default('Hello'), targets: Schema.array(Schema.string()).required(), })
Schema ≠ semantic validation. Schema: "provider" must be a string. Semantic: provider "foo" actually exists. Reject semantically impossible settings earliest.
Each entry describes one plugin
- id: worker
name: ./worker.ts
config: { concurrency: 4 }
disabled: false
id— stable identity for reconciliationname— module specifierconfig— plugin runtime optionsdisabled— keep in config, don't mount
Config file as a live source of truth
| new entry | → mount |
| deleted entry | → dispose |
| changed config | → update / restart |
| disabled → true | → unmount |
| disabled → false | → mount |
Loader is not just "a thing that reads YAML on boot." It reconciles configuration ↔ entry tree ↔ running fibers across changes.
Nested subtree
A group gives a subtree a shared lifecycle boundary — one agent profile, tenant, workspace, adapter bundle, provider stack, or feature set.
Same key, different providers
Groups can host isolated service scopes: consumers in A and B both ask for ctx.shell yet receive different local providers.
File-backed tree source
Loader manages the live tree; Include reads/writes one representation (cordis.yml, JSON). Supports initial config, patches by stable id, and computed !!js values.
Don't manually import & start every subsystem. Start Cordis + Loader, then let configuration declare the application: ctx.plugin(Loader) → ctx.loader.create({ name: '@cordisjs/plugin-include', config: { path: './cordis.yml' } }).
Runtime Services: HMR, Timer & Logging
These support packages make a Cordis application live, safe, and observable. None of them are required for the core model — but they make it practical.
Hot Module Replacement
Source change → affected modules → dispose old fibers → undo effects → load updated module → mount new fibers.
- Chokidar watcher + module dependency traversal
- partial reload of plugin code, full reload for framework
- injects
loader+timer
Safe only because effects are reversible. HMR is a great lifecycle test: if a plugin can't survive mount → unload → mount again, it has a bug.
Lifecycle-aware scheduling
Raw timers can outlive a plugin. Timer makes scheduling an owned, reversible effect. timer lifetime = plugin lifetime.
ctx.timeout(ms)— incl. awaitable sleepctx.interval(fn, ms)— no manual clear neededctx.throttle()/ctx.debounce()- deprecated:
setTimeout/setInterval
Scoped, attributable logs
Core contains logging infrastructure; logger-console is just one exporter. In a dynamic plugin system, logs should answer: which component emitted this?
const logger = ctx.logger('my-plugin') logger.info('started')
Plugins depend on logging semantics, not the console exporter.
A Complete Minimal Wired Example
A tiny directory wiring service provision, dependency injection, events, reversible effects, the timer, and loader config together.
export class GreeterService extends Service { constructor(ctx: Context) { super(ctx, 'greeter') } greet(name: string) { this.ctx.emit('greeter/called', name) return `Hello, ${name}!` } }
export const inject = ['greeter'] export function apply(ctx: Context) { const logger = ctx.logger('reporter') ctx.on('greeter/called', (name) => { logger.info('greeted %s', name) }) }
export const inject = ['timer'] export function apply(ctx: Context) { ctx.interval(() => { ctx.logger('heartbeat').debug('tick') }, 5000) }
- id: logger name: '@cordisjs/plugin-logger-console' - id: timer name: '@cordisjs/plugin-timer' - id: hmr name: '@cordisjs/plugin-hmr' - id: greeter name: './greeter.ts' - id: reporter name: './reporter.ts' - id: heartbeat name: './heartbeat.ts'
reporter requires greeter → Cordis waits → reporter loads → the listener belongs to reporter's fiber → reporter calls ctx.greeter → greeter emits → reporter observes it. Everything is lifecycle-owned and reversible.
Package-by-Package Guide
Nine packages in the upstream cordis/packages repository. Understand each role so you reach for the right one.
cordis · coreCore runtime
Always. The context, registry, fiber, effects, services, events, and logging infrastructure.
create-cordis · createScaffolding CLI
Developer bootstrap tool (npm create cordis). Not a runtime service.
@cordisjs/plugin-loaderDeclarative composition
The most important package after core. Entry → EntryTree → Fiber, plus reconciliation.
@cordisjs/plugin-includeFile-backed tree source
Read YAML/JSON into the loader; injects loader. Supports initial config, patches, computed config.
@cordisjs/plugin-groupNested subtree
Convenience surface over Loader's Group. Not an independent orchestration engine.
@cordisjs/plugin-hmrHot source reload
Injects loader + timer. Reload changed code with minimal impact on the running app.
@cordisjs/plugin-timerLifecycle-safe scheduling
timeout / interval / throttle / debounce, owned by the fiber.
@cordisjs/plugin-logger-consoleTerminal log output
One exporter of Cordis logs. Replaceable with JSON / OTel / files / GUI by depending on logging semantics.
@cordisjs/utilsInternal helper
private. Lifecycle-aware List<T>. Learn the pattern; don't build app dependencies on it.
| Need | Package |
|---|---|
| Basic plugin runtime | cordis |
| Start a new project | create-cordis |
| Config-driven plugin tree | @cordisjs/plugin-loader |
Read cordis.yml / JSON | @cordisjs/plugin-include |
| Nested entry subtree | @cordisjs/plugin-group |
| Hot source reload | @cordisjs/plugin-hmr |
| Lifecycle-safe timers | @cordisjs/plugin-timer |
| Terminal log output | @cordisjs/plugin-logger-console |
| Internal repo patterns | @cordisjs/utils |
Typical serious application: cordis + loader + include + timer + logger-console + hmr (in development) + your plugins.
Rules for Building Cordis Plugins
Do these and your components fit the model. The load → use → unload → assert cleanup → reload → assert no duplicates test is the ultimate gate.
- Declare required capabilities
export const inject = ['serviceName']— never assume loader order. - Use service keys, not provider imports
ctx.shell.execute()over a concrete provider import, soshellis replaceable. - Register runtime services separately from TS types
Type merge + runtime
super(ctx, 'foo')orctx.provide(). - Every external side effect needs a disposer
ctx.effect(() => { acquire(); return () => release(); }). - Prefer lifecycle-aware APIs
ctx.on,ctx.plugin,ctx.provide,ctx.timeout,ctx.interval. - Use events for policy / observation
Zero-or-many observers, unknown callers, intercepted or chained behavior.
- Use services for owned capabilities
One provider, direct calls: database, llm, tools, shell, sessions, metrics.
- Validate config before side effects
Schema validation must precede meaningful runtime acquisition.
- Give Loader entries stable IDs
Especially with HMR, patches, runtime updates, or nested groups.
- A plugin must survive a clean reload
load → use → unload → assert cleanup → load again → assert no duplicates.
Common Anti-patterns
✗ Register without an unregister path.
✗ Hidden mutable global state (
const clients = new Map()).✗ HMR used to hide bad lifecycle.
✓ Wrap in
ctx.effect() returning a disposer.✓ Own state in a service/plugin instance.
✓ HMR only works if the old plugin truly disposes.
Implementation Checklist
When implementing or reviewing a Cordis feature, walk this sequence from A to J.
Identify the component
- What is the plugin's responsibility?
- Behavior only, or a reusable service?
- Function plugin or
Servicesubclass?
Identify dependencies
- Which
ctx.<service>capabilities are required? - Add them to
inject. - Optional capabilities →
ctx.get().
Register service types
- Declaration merging on
Context. - Runtime registration via
Serviceorctx.provide(). - Never stop after the TS declaration.
Enumerate side effects
- Search for
on( addListener setInterval setTimeout watch open connect register subscribe add patch spawn. - Does Cordis already own it? Is it in
ctx.effect()? Does it return a disposer?
Decide communication style
- Service — one provider owns a capability.
- Event — 0..N observers/interceptors.
- Waterfall — wrap, transform, veto, short-circuit.
Validate config
- Standard Schema validator + defaults.
- Reject invalid before resource acquisition.
- Semantic validation where necessary.
Loader integration
- Stable
id;nameas module specifier. - Runtime options under
config. disabledrather than deleting; groups for subtrees; isolation for local providers.
HMR / reload test
- load → interact → reload → verify disposer runs → load new → no duplicate listeners/timers/services.
Dependency-loss test
- load provider → load consumer → unload provider → consumer unloads → restore provider → consumer reloads.
Diagnostics
- Something "does nothing"? Inspect fiber state.
- Check for
PENDING, injected names, provider mounted, module path. - Enable logger-console; inspect live effects & entry identity.
Glossary
Shared vocabulary across Cordis docs, this spec, and the DeepSeek Harness tutorial.
apply, or a Service class.ctxctx, e.g. ctx.tools, ctx.llm, ctx.database.export const inject = ['database'].inject.next() and can wrap or short-circuit downstream behavior.Don't think "where should I call startup and shutdown?" Think:
- What capability does this plugin provide?
- What capabilities does it require?
- What effects does it create — and what inverse disposes each?
- Who owns those effects?
- What happens if a dependency disappears?
- Can this fiber unload and mount again cleanly?
- Can Loader reconcile this from configuration?
If those questions have precise answers, the component fits the Cordis model. If not, fix the ownership / dependency model before adding more orchestration code.