Cordis · Meta-Framework

Cordis.

A meta-framework for dynamically composed applications — not a web, UI, agent, or app framework. It supplies the runtime rules for composing components safely.

mount a component expose capabilities declare dependencies react to change register side effects undo side effects reload components reconcile the tree
The Core Rule

Treat every plugin as a dynamically mountable component whose effects must be reversible and whose dependencies must be declared.

Application =
Context+Plugin tree
Services+Dependencies
Effects / disposers+Events
Loader reconciliation
Design Cordis apps as replaceable plugins — not one privileged core with hard imports.
01

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.

The runtime rules it provides

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

What it is not
  • Not a web framework
  • Not a UI framework
  • Not an agent framework
  • Not an application framework
What it provides
  • Composition runtime
  • Reactive dependency wiring
  • Reversible side effects
  • Lifecycle & reload semantics
  • Declarative tree reconciliation
Mental model

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.

02

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.

Temporal composability

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 → unregister
  • open socket → close
  • start interval → clear
  • register tool → unregister
  • provide service → remove
  • mount child plugin → dispose
Spatial composability

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.

instead of manual choreography
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.

Loading...

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.
03

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.

Loading...

The core owns the runtime model. The loader owns declarative composition. Everything else is itself a plugin or service built on that model.

04

The Core Building Blocks

Six primitives recur across every Cordis application. Master these and everything else is a composition of them.

Context — ctx

The 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.

Service

Named capability on ctx

Two distinct steps: type declaration merging (compile-time only) and runtime registration via Service or ctx.provide(). Both are required.

Inject

Declared required dependency

export const inject = ['greeter']. Cordis guarantees declared dependencies exist before apply() runs. Missing deps leave the plugin PENDING, not partially started.

Effect

A reversible side effect

ctx.effect(() => { acquire(); return () => release(); }). The body acquires, the return value disposes. Cordis tracks the disposer.

Fiber

One mounted plugin instance

ctx.plugin() returns a fiber owning config, dependency snapshot, lifecycle state, effects, child plugins, and cleanup.

Event

Typed loose coupling

One plugin emits, zero-to-many observe. Listeners are lifecycle-owned and disappear on unload — no plugin imports another's listener.

Context API surface
ctx.plugin(...) · ctx.effect(...)
ctx.get/provide/set/mixin(...)
ctx.on/emit/parallel/serial
ctx.bail/waterfall(...)
ctx.fiber · registry · logger · reflect
05

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.

Loading...
The effect rule

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().

socket
ctx.effect(() => {
  const socket = connect()
  socket.on('message', onMessage)
  return async () => {
    socket.off('message', onMessage)
    await socket.close()
  }
})
watcher
return () => watcher.close()
signal
return () => process.off('SIGTERM', h)
Cleanup order

LIFO — reverse registration

Disposers run in reverse registration order. Keep strict sequences inside one disposer rather than relying on independent async disposers.

labeled effect
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.

06

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.

Event naming
  • agent/step
  • tool/result
  • stats/report
  • session/updated
  • policy/check

Avoid generic names like update, change, done. The event namespace is flat — names should communicate ownership.

Declare · listen · emit
events.ts
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.

ModeTypeContractUse when
emitsyncBroadcast; everyone may observe, nobody controls the resultObservation / notification
parallelasyncListeners run concurrently; no order dependencyFan-out async work
serialasyncListeners run in order; first meaningful result may stop later onesOrdered async decision chains
bailsyncFirst listener that answers winsShort-circuit sync lookup
waterfallinterceptListeners receive next(); can observe, transform, wrap, veto, replacePolicy / middleware surfaces
Waterfall — policy / middleware interception

Listeners wrap, transform, or veto downstream behavior

demo/transform
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.

07

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.

Configuration & validation

Fail fast, before side effects

Define a Standard Schema (Schemastery) config validator. Reject invalid input before acquiring resources.

Config
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.

Loader entry

Each entry describes one plugin

cordis.yml
- id: worker
  name: ./worker.ts
  config: { concurrency: 4 }
  disabled: false
  • id — stable identity for reconciliation
  • name — module specifier
  • config — plugin runtime options
  • disabled — keep in config, don't mount
Reconciliation

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.

Group

Nested subtree

A group gives a subtree a shared lifecycle boundary — one agent profile, tenant, workspace, adapter bundle, provider stack, or feature set.

Service isolation

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.

Include

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.

Good booting pattern

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' } }).

08

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.

HMR

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.

Timer

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 sleep
  • ctx.interval(fn, ms) — no manual clear needed
  • ctx.throttle() / ctx.debounce()
  • deprecated: setTimeout / setInterval
Logging

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?

use
const logger = ctx.logger('my-plugin')
logger.info('started')

Plugins depend on logging semantics, not the console exporter.

08b

A Complete Minimal Wired Example

A tiny directory wiring service provision, dependency injection, events, reversible effects, the timer, and loader config together.

greeter.ts
export class GreeterService extends Service {
  constructor(ctx: Context) { super(ctx, 'greeter') }
  greet(name: string) {
    this.ctx.emit('greeter/called', name)
    return `Hello, ${name}!`
  }
}
reporter.ts
export const inject = ['greeter']
export function apply(ctx: Context) {
  const logger = ctx.logger('reporter')
  ctx.on('greeter/called', (name) => {
    logger.info('greeted %s', name)
  })
}
heartbeat.ts
export const inject = ['timer']
export function apply(ctx: Context) {
  ctx.interval(() => {
    ctx.logger('heartbeat').debug('tick')
  }, 5000)
}
cordis.yml
- 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.

09

Package-by-Package Guide

Nine packages in the upstream cordis/packages repository. Understand each role so you reach for the right one.

cordis · core

Core runtime

Always. The context, registry, fiber, effects, services, events, and logging infrastructure.

create-cordis · create

Scaffolding CLI

Developer bootstrap tool (npm create cordis). Not a runtime service.

@cordisjs/plugin-loader

Declarative composition

The most important package after core. Entry → EntryTree → Fiber, plus reconciliation.

@cordisjs/plugin-include

File-backed tree source

Read YAML/JSON into the loader; injects loader. Supports initial config, patches, computed config.

@cordisjs/plugin-group

Nested subtree

Convenience surface over Loader's Group. Not an independent orchestration engine.

@cordisjs/plugin-hmr

Hot source reload

Injects loader + timer. Reload changed code with minimal impact on the running app.

@cordisjs/plugin-timer

Lifecycle-safe scheduling

timeout / interval / throttle / debounce, owned by the fiber.

@cordisjs/plugin-logger-console

Terminal log output

One exporter of Cordis logs. Replaceable with JSON / OTel / files / GUI by depending on logging semantics.

@cordisjs/utils

Internal helper

private. Lifecycle-aware List<T>. Learn the pattern; don't build app dependencies on it.

NeedPackage
Basic plugin runtimecordis
Start a new projectcreate-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.

10

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.

  1. Declare required capabilities

    export const inject = ['serviceName'] — never assume loader order.

  2. Use service keys, not provider imports

    ctx.shell.execute() over a concrete provider import, so shell is replaceable.

  3. Register runtime services separately from TS types

    Type merge + runtime super(ctx, 'foo') or ctx.provide().

  4. Every external side effect needs a disposer

    ctx.effect(() => { acquire(); return () => release(); }).

  5. Prefer lifecycle-aware APIs

    ctx.on, ctx.plugin, ctx.provide, ctx.timeout, ctx.interval.

  6. Use events for policy / observation

    Zero-or-many observers, unknown callers, intercepted or chained behavior.

  7. Use services for owned capabilities

    One provider, direct calls: database, llm, tools, shell, sessions, metrics.

  8. Validate config before side effects

    Schema validation must precede meaningful runtime acquisition.

  9. Give Loader entries stable IDs

    Especially with HMR, patches, runtime updates, or nested groups.

  10. A plugin must survive a clean reload

    load → use → unload → assert cleanup → load again → assert no duplicates.

10b

Common Anti-patterns

Anti-pattern 1
Fix
function apply() { setInterval(work, 1000) }
ctx.interval(work, 1000) // or ctx.effect()
Anti-pattern 2
Fix
import db from './postgres-provider'
export const inject = ['database']; ctx.database.query(...)
Anti-pattern 3
Fix
await loadDatabase(); await loadApi(); await loadWorker()
api injects database; worker injects api
Anti-patterns 4–7
Remember
Type declaration ≠ runtime service.
Register without an unregister path.
Hidden mutable global state (const clients = new Map()).
HMR used to hide bad lifecycle.
Provide it at runtime too.
Wrap in ctx.effect() returning a disposer.
Own state in a service/plugin instance.
HMR only works if the old plugin truly disposes.
11

Implementation Checklist

When implementing or reviewing a Cordis feature, walk this sequence from A to J.

A

Identify the component

  • What is the plugin's responsibility?
  • Behavior only, or a reusable service?
  • Function plugin or Service subclass?
B

Identify dependencies

  • Which ctx.<service> capabilities are required?
  • Add them to inject.
  • Optional capabilities → ctx.get().
C

Register service types

  • Declaration merging on Context.
  • Runtime registration via Service or ctx.provide().
  • Never stop after the TS declaration.
D

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?
E

Decide communication style

  • Service — one provider owns a capability.
  • Event — 0..N observers/interceptors.
  • Waterfall — wrap, transform, veto, short-circuit.
F

Validate config

  • Standard Schema validator + defaults.
  • Reject invalid before resource acquisition.
  • Semantic validation where necessary.
G

Loader integration

  • Stable id; name as module specifier.
  • Runtime options under config.
  • disabled rather than deleting; groups for subtrees; isolation for local providers.
H

HMR / reload test

  • load → interact → reload → verify disposer runs → load new → no duplicate listeners/timers/services.
I

Dependency-loss test

  • load provider → load consumer → unload provider → consumer unloads → restore provider → consumer reloads.
J

Diagnostics

  • Something "does nothing"? Inspect fiber state.
  • Check for PENDING, injected names, provider mounted, module path.
  • Enable logger-console; inspect live effects & entry identity.
12

Glossary

Shared vocabulary across Cordis docs, this spec, and the DeepSeek Harness tutorial.

Component
Conceptual dynamically composable unit — represented by a plugin/fiber plus its effects & dependencies.
Plugin
Executable Cordis component: a function, an object with apply, or a Service class.
Context / ctx
Shared scoped runtime surface carrying services, events, lifecycle APIs, and metadata.
Service
Named capability exposed on ctx, e.g. ctx.tools, ctx.llm, ctx.database.
Inject
Declaration of required service dependencies, e.g. export const inject = ['database'].
Effect
A side effect whose inverse/disposer Cordis tracks.
Disposer
Function that reverses an effect.
Fiber
One mounted plugin instance and its lifecycle state, effects, config, dependencies.
Temporal composability
Ability to remove a component and completely reverse its side effects.
Spatial composability
Ability to declare dependencies and react when the capability environment changes.
Coeffect
What a component requires from its environment; in practice, required services via inject.
Loader
Runtime that turns a declarative entry tree into mounted plugins and reconciles changes.
Entry
Loader's description of one desired plugin/group node.
Include
File-backed source for a Loader entry tree.
Group
Nested Loader subtree with a shared lifecycle boundary.
HMR
Hot Module Replacement — reload changed code with minimal impact on the rest of the running app.
Waterfall
Around-middleware event dispatch where listeners receive next() and can wrap or short-circuit downstream behavior.
Final coding-agent instruction

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.