Cordis Runtime From First Principles

Listen with Article TTS Reader
Checking for Article TTS Reader…
Cordis can look like a collection of framework vocabulary: Context, Service, Plugin, events, Lifecycle, and inject. Memorizing those names misses the design pressure that created them.
Start with a small program whose capabilities can change while it is running. At first, ordinary TypeScript is enough. As modules begin communicating, allocating resources, and depending on capabilities that may come and go, the work becomes less about calling objects and more about keeping the system consistent. Cordis is a runtime for that second problem.
This article is a summarized English adaptation of Cordis From Zero, a Chinese-language learning series by Bange. The accompanying example repository is useful when you want to compare the ideas with runnable code.
Begin with the version that needs no framework
Suppose a program chooses between English and Chinese greetings at runtime:
interface Greeter {
greet(name: string): string
}
const englishGreeter: Greeter = {
greet: (name) => `Hello, ${name}!`,
}
const chineseGreeter: Greeter = {
greet: (name) => `Ni hao, ${name}!`,
}
let greeter: Greeter = englishGreeter
function app() {
console.log(greeter.greet('Alex'))
}
app()
greeter = chineseGreeter
app()
There is no lifecycle problem here. A variable has one owner, switching means assignment, and the rest of the program can use the current value. Introducing a runtime at this point would add ceremony without paying for itself.
The boundary changes when greeter stops being a private implementation detail and becomes a capability other modules need to discover, replace, observe, and clean up. A direct variable can still work, but the program now has to recreate runtime rules by hand.
Context, Service, and Plugin establish ownership
Cordis gives the running system a capability view called Context. A Service is a named capability exposed through that view. A Plugin installs a service or related behavior and owns the resources it creates.
At a high level, the relationship is:
Plugin installs a Service
Service is visible on Context
Context exposes the capabilities currently available
A service can therefore look like this:
import { Context, Service } from 'cordis'
abstract class Greeter extends Service {
constructor(ctx: Context) {
super(ctx, 'greeter')
}
abstract greet(name: string): string
}
Other code uses ctx.greeter rather than retaining a particular implementation. The important change is architectural: callers depend on the runtime capability named greeter, while a plugin owns the concrete implementation that made it available.
That ownership becomes visible during replacement. Installing a second provider under an occupied service name is an error until the existing plugin is disposed. Cordis declines to silently overwrite the service because a replacement has consequences beyond the current value: event listeners, timers, connections, and child resources may need cleanup. dispose() establishes a boundary where the old implementation stops being responsible for anything.
Events solve the communication problem, then expose the cleanup problem
Now add a logger. The application should announce that a greeting happened, while the greeter and application should remain unaware of every observer that cares about that event.
An event is a good fit:
declare module 'cordis' {
interface Events {
greet(name: string): void
}
}
function app(ctx: Context) {
console.log(ctx.greeter.greet('Alex'))
ctx.emit('greet', 'Alex')
}
function logger(ctx: Context) {
ctx.on('greet', (name) => {
console.log(`[log] greeted ${name}`)
})
}
Writing emit() and on() is not difficult. A hand-rolled event emitter can hold an array of listeners. The difficult part is making listener removal dependable after a module is unloaded. If the logger is removed but its callback remains registered, the system gains a stale listener: it can duplicate output, retain memory, or touch state that should no longer exist.
Cordis treats the listener created by ctx.on() as a resource belonging to the current plugin lifecycle. Disposing that plugin removes its listener along with the rest of its resources. This is why lifecycle management is more fundamental than the event API. The runtime is binding creation and cleanup to the same owner.
Dynamic dependencies turn injection into an ongoing responsibility
Constructor injection works well when a dependency is created first and lives for the entire process. Runtime dependencies often have a different shape. A clock service might load after the greeter, go away while the program runs, and later return.
In direct code, someone has to keep the dependent capability synchronized:
let clock: Clock | undefined
let greeter: Greeter | undefined
function refreshGreeter() {
greeter = clock ? makeGreeter(clock) : undefined
}
function loadClock(next: Clock) {
clock = next
refreshGreeter()
}
function unloadClock() {
clock = undefined
refreshGreeter()
}
This works until one code path changes clock and forgets to call refreshGreeter(). The process remains alive in an inconsistent state: the dependency is absent, but a dependent object may still hold a stale reference. The defect often appears later, far from the missed refresh.
Cordis lets a service declare the relationship:
abstract class Greeter extends Service {
static inject = ['clock']
constructor(ctx: Context) {
super(ctx, 'greeter')
}
}
Here, inject means more than "pass clock into my constructor." It tells the runtime that the greeter's availability is conditional on the clock service. If the clock is missing, the greeter remains inactive. When the clock becomes available, the runtime can activate the greeter. When the clock is disposed, the greeter becomes unavailable too, and it can be activated again after the dependency returns.
The declaration moves dependency knowledge to the module that has the dependency. The loader no longer needs a growing list of refreshSomething() calls.
Scale changes the economics
The value of this model becomes clearer when two independent capabilities depend on the same service:
Clock
/ \
v v
Greeter Farewell
With manual synchronization, every clock transition now needs to refresh both consumers. Add notifications, scheduling, or analytics and the code that loads the clock accumulates knowledge about every dependent module. The dependency graph has leaked into orchestration glue.
With Cordis, each consumer declares static inject = ['clock']. The runtime owns the activation relation; the clock does not need to know its dependents, and the bootstrap path does not need to enumerate them. A new dependent mainly adds its own declaration, rather than modifications across every lifecycle transition.
This does not make a runtime mandatory for small programs. It explains why the tradeoff changes in long-running and extensible systems. Manual wiring costs little when the graph is stable and small. Dynamic services, plugins, and multiple dependency consumers turn that wiring into a correctness problem that repeats at every state transition.
Cordis as a runtime model
The concepts now form a single model:
| Concept | Runtime question it answers |
|---|---|
Context |
Which capabilities are currently available? |
Service |
What named capability does a module provide? |
Plugin |
Who installed this capability and owns its resources? |
| Event | How can modules communicate without direct references? |
| Lifecycle | When is a resource created, active, and cleaned up? |
inject |
Which capabilities must remain available for this one to activate? |
Lifecycle is the thread through all six. Plugins install and dispose. Services become available and unavailable. Event listeners register and are removed. Dependencies appear, disappear, and can be restored. Cordis coordinates those transitions so that ownership and dependency state remain explicit.
That is also why the model maps well to agent runtimes. An agent application may load a model provider, tool server, memory backend, evaluator, or browser connector dynamically. Other components may depend on each of those capabilities. A connection failure, an unloaded plugin, or a changed profile should deactivate the affected behavior cleanly rather than leaving partial objects and stale listeners behind.
Cordis is therefore better understood as a dynamic-capability runtime than as a dependency-injection container with extra APIs. The framework earns its complexity when the system must continuously answer: what is available, who owns it, what depends on it, and what must be cleaned up when it disappears?
Continue the original series
The public collection opens with a Runtime Foundations introduction, then develops the same path in five parts: