DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

SheltonLiu-N /

nano-cordis

Topic repository only

A nano re-implementation of Cordis and DeepSeek Harness, an AI agent runtime built out of plugins, small enough to read in an afternoon.

★ 5 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@caea7b06

NanoCordis

English | 中文

NanoCordis is a small, readable codebase that answers one question: how do you build an AI agent runtime out of plugins? It contains two layers, about 1600 lines of TypeScript in total:

  • src/cordis/ — a minimal re-implementation of the Cordis plugin framework that keeps every core idea (plugins, services, dependency-driven loading, effects, events, configuration-driven composition, hot reload) and leaves out the rest.
  • src/harness/ — a minimal agent harness built on it, in the shape of DeepSeek Harness: a session log that is the only source of truth, a loop that asks a model and runs its tool calls, a tool pipeline, an approval policy, a command line — each one a plugin.

You can read the whole thing in an afternoon. Wherever the real projects do more at a specific place in the code, a short // Omitted: or // Differs: comment says what they do and why; whole subsystems that are simply absent are listed at the end of this document. Either way you always know where the full version goes further.

Why this exists

An agent harness is the program around a language model: it keeps the conversation, decides what the model sees, executes the tools the model asks for, and talks to the user. Every such program ends up with the same tension: it needs many replaceable parts (models, tools, storage, interfaces, policies), and those parts need to find each other without hard-wiring.

Cordis is a plugin framework built for exactly that. Its core idea is that a program is a set of plugins mounted into a shared context: a plugin registers services other plugins can use, declares which services it needs, and everything it registers is undone automatically when it is unloaded. Load order falls out of dependencies, and reloading a plugin is just unload plus load. DeepSeek Harness (dsh) applies this idea to an agent runtime without exception: the model adapter, the tool registry, the session log, even the agent loop are plugins.

Both real projects are large. NanoCordis keeps their design and drops their size, so you can see the mechanism with nothing in the way.

Quick start

Requirements: Node.js 20 or newer.

Try it without cloning — the package is on npm:

npx nano-cordis "Say hello"        # or: npm install -g nano-cordis, then nano-cordis "Say hello"

To read and change the code, clone this repository:

npm install
npm start -- "Say hello"

The default configuration uses a scripted fake model (no API key needed). It asks to run one bash command, so answer y:

[tool] bash {"command":"echo hello from nano"}
Allow bash {"command":"echo hello from nano"}? [y/N] y
[result] hello from nano

[exit code: 0]

assistant> The command ran; that is all for this scripted reply.

Run npm start with no argument for a chat prompt (you>), and npm start -- --resume <session id> to continue a saved session (the id is printed when the chat starts; logs live in .nano/sessions/).

Install the command. npm install -g nano-cordis gives you a global nano-cordis command; from a clone, npm link registers the same command pointing at your working copy. Either way it is the launcher npm start uses, and it runs from any directory: the directory's own cordis.yml is read when present, the one shipped with the package otherwise, and session logs go to .nano/sessions/ under the directory you run from.

Use a real model. In cordis.yml, replace the llm entry with an OpenAI-compatible endpoint. The key is read from the named environment variable, never written into the file:

- id: llm
  name: ./src/harness/llm-openai.ts
  config: { baseUrl: https://api.deepseek.com/v1, apiKeyEnv: DEEPSEEK_API_KEY, model: deepseek-v4-flash }

Watch hot reload work. Start npm start, then edit cordis.yml: change the approval entry to tools: [] and save. The terminal prints [nano-cordis] applied cordis.yml, and the next bash call runs without asking. Now edit src/harness/tool-bash.ts (say, its description) and save: [nano-cordis] reloaded ./src/harness/tool-bash.ts — the old plugin's registrations vanished and the new one's took their place. If you change an entry that other plugins depend on (the model, for instance), those plugins stop and start again too; the command line will greet you with a fresh session, which is the dependency model doing exactly what it says.

Check everything: npm test (95 tests) and npm run typecheck.

Repository map and reading order

Read the files in this order; each file header says what it mirrors in the real projects and what to watch for.

# File What it is
1 bin.ts The launcher: create a context, mount the loader, mount cordis.yml.
2 cordis.yml The application: one plugin per entry.
3 src/cordis/context.ts The context: service container, plugin(), provide(), effect(), and the caller-bound view of a service.
4 src/cordis/plugin.ts The three plugin shapes and config validation.
5 src/cordis/fiber.ts A running plugin: its state, its effects, start and stop.
6 src/cordis/refresh.ts The scheduler that starts and stops plugins as services come and go.
7 src/cordis/events.ts The event bus and its four dispatch modes.
8 src/cordis/service.ts The base class for plugins that provide a service.
9 src/cordis/loader.ts Reads cordis.yml; mounts, remounts and unmounts by entry id.
10 src/cordis/hmr.ts Watches files and reloads what changed.
11 src/harness/session.ts (+ freeze.ts) The session log and the messages derived from it; freeze.ts is the small helper that freezes what was logged.
12 src/harness/prompt.ts System prompt sections.
13 src/harness/llm.ts, llm-openai.ts, llm-fake.ts The model service definition and two providers.
14 src/harness/tools.ts The tool registry and pipeline.
15 src/harness/agent-loop.ts The turn: ask the model, run tools, repeat.
16 src/harness/shell.ts, shell-local.ts, tool-bash.ts One capability in three parts.
17 src/harness/approval.ts A policy plugin.
18 src/harness/persistence.ts Saving and resuming sessions.
19 src/harness/cli.ts The command line, a subscriber like any other plugin.

Tests live in tests/cordis/ and tests/harness/; tests/fixtures/agent.cordis.yml boots the whole agent from a file inside a test.

Part 1 — the mini Cordis, idea by idea

1. A plugin is a function, a class, or an object with apply

// function form: what a module with named exports looks like to the loader
export const name = 'hello'
export const inject = ['prompt']            // services this plugin needs
export function apply(ctx: Context) {       // runs once the services exist
  ctx.prompt.section('hello', 10, 'Always greet the user first.')
}

A class form is usually a Service subclass (see idea 6), though any class works. The loader hands it a module namespace, which is the object form; a default export replaces the namespace. plugin.ts detects the shape and validates the config; context.ts mounts it with ctx.plugin(plugin, config).

2. A context is a container of services; inject says what a plugin needs

ctx.provide('prompt', value) puts a service in the shared table. A plugin lists the services it needs in inject; it starts only when they all exist, stops when one disappears, and starts again when it comes back (refresh.ts). Nobody orders the plugins by hand — mount them in any order and they sort themselves out. Reading ctx.prompt inside a plugin that did not declare prompt throws (a plugin may always read the services it provides itself, and those an enclosing plugin declared or provides); reading a declared service that has gone throws too, rather than handing back undefined. ctx.get('prompt') is the escape hatch for optional services: it returns the service when its provider is running and undefined otherwise.

3. Plugins start in a microtask: await ctx.plugin(...)

ctx.plugin(x) returns immediately with the plugin's fiber (its running instance) and starts it a moment later, once the current code has finished. Write await ctx.plugin(x) before using what x provides; the same rule holds in the real framework. If a plugin's config is invalid or its body throws, the await rethrows the error and the fiber's state is failed. A fiber changes state one step at a time: a stop that arrives while the body is still running waits for it to finish (Cordis serializes the same way and calls the change under way inertia).

4. Everything you register is an effect

ctx.effect(() => {
  const timer = setInterval(tick, 1000)
  return () => clearInterval(timer)         // runs when the plugin unloads
})

ctx.on(...), ctx.provide(...) and ctx.plugin(...) are all built on ctx.effect, and a function returned by the plugin body itself counts as its cleanup. Unloading a plugin runs its cleanups in reverse order, which is why hot reload needs no special support: unload, then load again. ctx.effect returns a disposer: the first call runs the cleanup once and resolves when it is done; a later call returns at once instead of waiting (so a cleanup can never end up waiting for itself), while an unload always waits for a run already under way.

5. Events: four ways to dispatch

Call Behavior
ctx.emit(name, ...args) Every listener runs, in order; results are ignored.
await ctx.parallel(name, ...args) Listeners run at the same time; all are awaited, and every failure is reported together in one AggregateError.
await ctx.serial(name, ...args) Listeners run one by one; the first meaningful return value wins.
ctx.waterfall(name, ...args, next) Middleware: each listener receives a next(); skipping it short-circuits everything after it.

Waterfall is what makes interception possible — a policy can veto a tool call by returning without calling next() — and it comes with one rule: a listener that only observes must call next(). Event names and signatures are declared by extending the Events interface (declare module '../cordis/index.ts' { interface Events { ... } }); the type of ctx.<name> is declared the same way (interface Context { prompt: Prompt }). A file that only needs those declarations imports the module for its types alone: import type {} from './tools.ts' — you will see this line at the top of several harness files.

6. Services and the caller-bound view

A Service subclass registers itself with super(ctx, name). When another plugin reads it through ctx.<name>, it receives a view of the same object in which this.ctx belongs to the caller. So when tools.register(...) runs this.ctx.effect(...), the registration is owned by the plugin that called register, and disappears when that plugin unloads — even though the code lives in the tools service. Service reads inside the method (this.ctx.llm) still follow the service's own inject. Cordis calls this a traceable; context.ts implements it in bindToCaller.

7. Config is validated before a plugin starts

A plugin exports a Config schema (any Standard Schema validator; this repository uses schemastery). Defaults are filled in and the checked object is passed to apply; a bad cordis.yml entry names the field (with its path, such as inner.deep) and the plugin never starts half-configured.

8. cordis.yml and hot reload

The loader reads a list of entries { id, name, config, disabled }, mounts each as a child plugin, and on a re-read compares by id: removed entries unmount, changed ones remount, new ones mount. An entry whose module fails to load is reported and remembered, so the next save of the config or of that module tries again; a module save that does not even load changes nothing. hmr.ts watches the directory and calls the loader. Both are plugins themselves.

Five rules for writing a plugin

  1. Declare in inject every service you read as ctx.<name>; use ctx.get(name) only for optional ones.
  2. Register everything through ctx.effect (or ctx.on, ctx.provide, ctx.plugin, which use it) — never keep a listener or timer that the framework does not know about.
  3. await ctx.plugin(x) before you use what x provides.
  4. Inside a service method, this.ctx belongs to the caller. Registrations that belong to the service itself use the context saved at construction time (see how Loader keeps host).
  5. Do not use #private fields in a service; the caller-bound view cannot read them.

Part 2 — the nano harness, mechanism by mechanism

1. The session log is the only source of truth

session.ts keeps an append-only list of events: turn/start, step/start, user/message, request/header, assistant/message, tool/call, tool/result, step/end, turn/end. append() is the only way to write (session.events is a frozen copy); each event gets the next seq, is frozen, and is broadcast as session/event — a listener may not append while that broadcast is under way, so everyone sees events in log order. The messages sent to the model are derived from the log by deriveMessages(), never stored separately — so replay, resume and inspection all read the same thing.

2. Nothing reaches the model that is not on record

Before each request the loop logs a request/header (model, system prompt, tool schemas) whenever it changed, and then builds the request from the last logged header plus the derived messages. Plugins can adjust the header through the agent/request waterfall — but only what gets logged. In dsh this is the standing rule "model-visible means logged", checked at runtime; here it holds by construction.

3. Turns and steps

A turn starts with the user's message and ends when the model answers without tool calls (completed), when the step limit is hit (max-steps), or on an error. A step is one model request plus the tool calls it produced. agent-loop.ts writes turn/end in a finally, so a log always closes what it opened.

4. Tools: a registry and a pipeline

tools.ts holds the definitions and runs every call through the same pipeline: tools/pre-execute (allow or deny) → the tool body → tools/post-execute (rewrite the result). Whatever fails — unknown tool, denied call, invalid arguments, a throwing body — becomes a result with isError: true; the loop never has to catch anything. schemas() (what the model sees) and execute() (what can run) read the same table, so a tool that is gone is gone from both.

5. One capability, three parts

The shell capability is split the way dsh splits every replaceable capability: shell.ts is the service definition (request, spec, result, and an explicit resolve() that fills defaults), shell-local.ts is a provider (bash on this machine), tool-bash.ts is a consumer that exposes it to the model. The consumer depends on the definition only; swap the provider line in cordis.yml and nothing else changes. The model service is split the same way (llm.ts / llm-openai.ts / llm-fake.ts).

6. Policy is a plugin

approval.ts listens on tools/pre-execute; for the tools it guards it asks approval/request and denies unless someone answers exactly true (a "yes" or a 1 counts as a refusal, so a broken answerer can only deny, never allow). The command line answers by asking you; a test answers automatically; if nobody answers, the call is denied. Delete the entry from cordis.yml and there is no approval — the loop and the tools were never involved.

7. Prompt sections

prompt.ts assembles the system prompt from named sections ordered by number. A section is an effect owned by the plugin that added it.

8. Persistence and resume

persistence.ts appends every event to .nano/sessions/<id>.jsonl and resumes a session by creating it again with the file's events as its history (which is why Session accepts a seed that is not broadcast). A log that stops in the middle of a turn is refused rather than guessed at.

9. The command line is a subscriber

cli.ts prints session/events, answers approval/request, and hands input to agentLoop.run(). Nothing in the loop knows it exists; a web interface would be another plugin doing the same.

10. cordis.yml is the application

Look at the file: sessions, persistence, prompt, model, tools, shell, bash tool, approval, loop, cli, hot reload — eleven entries, in no particular order. Change the model, drop the approval, replace the shell: edit the file, and if hmr is running, watch it happen.

One turn, step by step

user text → cli → agentLoop.run(session, text)
  append turn/start
  step 1..maxSteps:
    append step/start                       (the first step also appends user/message)
    config = waterfall('agent/request', { model: llm.model, system: prompt.render() })
    header = config + tools.schemas()       → append request/header if it changed
    request = last logged header + session.deriveMessages()
    reply = llm.chat(request)               → append assistant/message
    no tool calls → append step/end, turn/end (completed) → done
    for each call: append tool/call → tools.execute → [pre-execute ▷ (approval ▷) → body → post-execute ▷] → append tool/result
    append step/end

Exercises

  1. Add a tool. Copy tool-bash.ts into tool-date.ts returning the current date, add an entry to cordis.yml, and watch it appear in the next request/header.
  2. Swap the model. Point the llm entry at llm-openai.ts with your endpoint. Nothing else changes.
  3. Write a policy. A plugin that listens on tools/pre-execute and denies any bash command containing rm -rf. Remember to call next() for everything else.
  4. Change the persona live. With npm start running, edit persona: in cordis.yml and save. The prompt plugin remounts, the loop and the command line restart with it, and the command line greets you with a new session; send one message, then look at the new session's .nano/sessions/<id>.jsonl for the new request/header.
  5. A read-only shell. Write shell-readonly.ts extending LocalShell whose run() refuses any command not starting with ls, cat or echo before calling super.run(), and put it in place of shell-local.ts.

What is left out on purpose

Cordis features not implemented (whole features are listed here; where an omission touches a specific line, a // Omitted: comment sits there too): isolated service realms and per-service config interception; listener filtering by context (the basis of dsh's per-agent scopes); bail and once; generator effects and awaited async setup; callable services, Service.check and async init; ctx.set / accessor / mixin; the per-service config carried by the object form of inject (its keys still name the required services); automatic retry of failed plugins; in-place config updates with internal/update; internal events; the logger; loader groups, transactional rollback, write-back and !!js expressions; HMR through Node's module graph.

Where NanoCordis deliberately behaves differently: effects are accepted only while a plugin is active; cleanups run one by one instead of concurrently; emit isolates a throwing listener; a failed plugin stays failed; waterfall's next is composed per listener; class plugins are detected by the class keyword; entries without an id are rejected; a config change remounts the entry; a module that fails to reload is reported per entry, without rolling anything back.

dsh mechanisms not implemented: streaming and per-chunk logging; the inbox with follow-up, steering and injected context; agent/pre-step, agent/turn-stopping, agent/request-error; cancellation; the agent registry and handles; scopes and per-session presets; subagents, workflows, background jobs; context compaction; sandboxing; multi-provider routing and retry; approval audit events and policies; the tools/execute wrapper, guards, tools/result; parallel tool calls; crash repair; forks; projections, search, telemetry; bundles, profiles and patch layers; the web UI, SDK and ACP. The log uses the OpenAI message format directly instead of dsh's provider-neutral one.

Where to look in the real code:

NanoCordis Real project
src/cordis/context.ts cordis packages/core/src/context.ts, reflect.ts
src/cordis/plugin.ts cordis registry.ts
src/cordis/fiber.ts, refresh.ts cordis fiber.ts
src/cordis/events.ts cordis events.ts
src/cordis/service.ts cordis service.ts
src/cordis/loader.ts @cordisjs/plugin-loader, plugin-include
src/cordis/hmr.ts @cordisjs/plugin-hmr
src/harness/session.ts dsh packages/core/session
src/harness/persistence.ts dsh packages/session/session-persistence, -jsonl
src/harness/prompt.ts dsh packages/core/system-prompt
src/harness/llm*.ts dsh packages/llm/llm, llm-deepseek
src/harness/tools.ts dsh packages/core/tools
src/harness/shell*.ts, tool-bash.ts dsh packages/shell/shell, bash-local, tool-bash
src/harness/approval.ts dsh packages/interaction/user-approval and the tools/pre-execute policy pattern
src/harness/agent-loop.ts dsh packages/core/agent, agent-loop
src/harness/cli.ts dsh packages/bundle/headless, apps/cli

Tests

npm test runs vitest: tests/cordis/ covers effects, dependencies, startup timing, events, caller binding, config, the loader and hot reload; tests/harness/ covers the log, the tool pipeline, a full turn, approval, persistence, the scripted model, and booting the agent from tests/fixtures/agent.cordis.yml.

Credits and license

Cordis is developed by the cordiverse organization; its design is described in A Programming Paradigm for Spatiotemporal Composability. DeepSeek Harness is developed by DeepSeek. NanoCordis re-implements their ideas for teaching and is not affiliated with either. MIT license.

The logo is eleven blocks, one per entry in cordis.yml; the red one is the plugin being reloaded.

—/ 5

No ratings yet

Manifest verification required

Commit caea7b0641c5

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout