Building a Mini DeepSeek Agent Runtime from Scratch with Cordis
Synchronously updated to personal site: Building a Mini Harness from Scratch with Cordis
Starting from a hello world plugin, gradually using services, dependency injection, and events to build a mini Agent runtime framework that can call the DeepSeek API and execute bash/fetch/file search.
The previous article "A Quick Look at DeepSeek Harness" discussed the "everything is a plugin" philosophy. This one gets hands-on: using its foundation, Cordis, to assemble a mini harness from scratch — eight plugins, three services, two events, one Agent loop, and finally letting the model search code, execute commands, and answer your questions.
Chapter 1 · The First Plugin
What This Tutorial Does
We are building a mini version of DeepSeek Harness: a minimal runtime framework that can call a model, execute real tools (shell commands, HTTP requests, file search), and run an Agent loop. Its gap from the real Harness is summarized in the last chapter, but the core mechanism is completely identical—because it uses the same foundation, Cordis.
First, clarify what "assembling a harness" means. The previous article explained: a harness has no kernel; all capabilities are plugins. So our construction method is not "write a main program and add features to it," but rather break down capabilities into individual plugins, and finally assemble them with a few lines of code. Each step in this tutorial is one of those plugins.
What we end up assembling looks like this:
Eight plugins, three services, two events. mini-llm, mini-tools, mini-agent provide services; three tool plugins (bash, fetch, search) depend on tools via inject; mini-prompt and tracer participate in events via ctx.on. There is no "main program"—only plugins and the relationships between them.
Prerequisites
- Node.js ≥ 18
- DeepSeek API Key (apply on the official website, environment variable
DEEPSEEK_API_KEY)
The complete code for this tutorial is a standalone project in the repository's project/ directory (GitHub source code). After cloning the entire repository, enter the project directory and install dependencies:
cd content/tutorials/cordis-mini-harness/project
npm install
Note the package name is cordis—there is also a @cordisjs/core on npm, which is a legacy package name from the v3 era; don't install the wrong one. Run TypeScript with tsx (Cordis's own modules use extensionless imports and must be run via tsx or a bundler; native Node cannot run them). The project already has an npm start script configured.
What a Minimal Plugin Looks Like
import { Context } from 'cordis'
const app = new Context()
app.plugin({
name: 'hello',
apply(ctx: Context) {
console.log('hello from my first plugin')
},
})
Save this as any .ts file (e.g., hello.ts), and run it with npx tsx hello.ts: the console will print hello from my first plugin. This file is just a warm-up and is not part of the final project.
A Cordis application is one Context. new Context() creates the root context, and app.plugin(...) mounts a plugin onto it. A plugin is an object with name and apply(ctx)—name is an identifier for diagnostic information, and apply is the entry point; the framework calls it during loading and hands the context to you.
Plugins come in three forms: function (write apply directly), object (with fields like above), class (Service subclass, used in the next two steps). All three are equivalent at runtime; which one you choose depends on whether you need to carry state.
Chapter 2 · Services: Mounting Capabilities onto ctx
Why Services Are Needed
The hello plugin only proves one thing: plugins can be loaded. But what a harness needs is not "isolated code," but capabilities that can be discovered and used by other plugins. Cordis's answer is called a Service: a plugin mounts a capability onto the context, and other plugins find it by name, rather than importing its specific implementation.
import { Service, type Context } from 'cordis'
declare module 'cordis' {
interface Context {
llm: LlmService
}
}
export interface LlmConfig {
model?: string
baseUrl?: string
}
export interface ChatMessage {
role: 'system' | 'user' | 'assistant' | 'tool'
content: string
}
export class LlmService extends Service {
config: Required<LlmConfig>
constructor(ctx: Context, config: LlmConfig = {}) {
super(ctx, 'llm')
this.config = {
model: config.model ?? 'deepseek-v4-flash',
baseUrl: config.baseUrl ?? 'https://api.deepseek.com',
}
}
async chat(messages: ChatMessage[]): Promise<string> {
const res = await fetch(`${this.config.baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`,
},
body: JSON.stringify({
model: this.config.model,
messages,
}),
})
if (!res.ok) {
throw new Error(`LLM call failed: ${res.status} ${await res.text()}`)
}
const data = await res.json()
return data.choices[0].message.content as string
}
}
export const name = 'mini-llm'
export function apply(ctx: Context, config?: LlmConfig) {
ctx.plugin(LlmService, config)
}
This is the project's llm.ts—the harness's "model calling" capability. Let's go through it line by line:
Lines 3-7, declare module is TypeScript's declaration merging: it tells the type system that ctx now has an llm property. There is no runtime side effect, but with it, any place that gets a Context can write ctx.llm.chat(...) with types—this is the type foundation for the previous article's "find services by key, not by importing specific implementations."
LlmService extends Service is the combination of plugin and service: it is a plugin (can be mounted by ctx.plugin()), and simultaneously registers itself as a service named llm via super(ctx, 'llm'). Registration happens at construction time and is automatically removed at disposal—service registration itself is a reversible side effect.
Configuration goes through the second parameter of apply(ctx, config): model defaults to deepseek-v4-flash (the old model name deepseek-chat was discontinued in July 2026; switch to deepseek-v4-pro when stronger reasoning is needed), baseUrl defaults to the official API address. Only default value merging is done here; the real Cordis ecosystem uses Schemastery (standard Schema) for runtime validation, simplified for the tutorial scenario.
The chat() method is a standard OpenAI-compatible request, returning the plain text of the model's reply. It doesn't know what tools are yet—that's the next step.
Chapter 3 · Tool Registry
Build the Registry First, Then Register Tools
import { Service, type Context } from 'cordis'
declare module 'cordis' {
interface Context {
tools: ToolsService
}
}
export interface Tool {
name: string
description: string
parameters: Record<string, unknown>
execute(args: Record<string, unknown>): Promise<string> | string
}
export class ToolsService extends Service {
private tools = new Map<string, Tool>()
constructor(ctx: Context) {
super(ctx, 'tools')
}
register(tool: Tool) {
this.tools.set(tool.name, tool)
}
describe(): string {
return [...this.tools.values()]
.map(
(tool) =>
`- ${tool.name}: ${tool.description}, parameters ${JSON.stringify(tool.parameters)}`,
)
.join('\n')
}
async execute(name: string, args: Record<string, unknown>): Promise<string> {
const tool = this.tools.get(name)
if (!tool) throw new Error(`Unknown tool: ${name}`)
return await tool.execute(args ?? {})
}
}
export const name = 'mini-tools'
export function apply(ctx: Context) {
ctx.plugin(ToolsService)
}
This is tools.ts. It defines a tool protocol: each tool must have a name, description, parameters (a JSON Schema fragment, written for the model to see), and an execute function.
ToolsService is a registry: register() registers a tool, describe() renders the registered tools into a list for the model to see, and execute() executes by name. Note that it does not provide any specific tools—it is only responsible for "can register, can execute."
Where do specific tools come from? The three plugins in the next chapter.
Chapter 4 · Three Real Tools
Tools Are Not Configuration Items, They Are Plugins
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import type { Context } from 'cordis'
const execAsync = promisify(exec)
export const name = 'mini-bash'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register({
name: 'bash',
description: 'Execute a shell command and return the output (local only, 10-second timeout)',
parameters: {
type: 'object',
properties: {
command: { type: 'string', description: 'The command to execute' },
},
required: ['command'],
},
async execute(args) {
const command = String(args.command ?? '')
try {
const { stdout, stderr } = await execAsync(command, { timeout: 10_000 })
return (stdout + stderr).trim() || '(no output)'
} catch (error) {
return `Command execution failed: ${(error as Error).message}`
}
},
})
}
This is bash.ts—the meaning of "everything is a plugin" at the tool level: tools are not configuration items of the registry, but independent plugins. mini-bash uses inject: ['tools'] to declare its dependency on the tools service—when the framework sees this line, it waits for tools to be ready before loading it. The startup order is derived from dependencies, requiring no orchestration code.
Two points in the implementation: execAsync wraps a promisify, with a 10-second timeout to prevent commands from hanging; stdout and stderr are concatenated and returned. Errors are not thrown outward but returned as strings to the model—a tool failure is also information; the model can adjust its strategy based on it. Throwing an exception would only crash the loop.
The other two follow the exact same pattern and can be viewed side-by-side:
import type { Context } from 'cordis'
export const name = 'mini-fetch'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register({
name: 'fetch',
description: 'Request an http/https URL and return the status code and text content',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'The URL to request' },
method: { type: 'string', description: 'HTTP method, default GET' },
body: { type: 'string', description: 'Request body (JSON string, optional)' },
},
required: ['url'],
},
async execute(args) {
const url = String(args.url ?? '')
const method = String(args.method ?? 'GET')
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: args.body ? String(args.body) : undefined,
})
const text = await res.text()
return `${res.status}\n${text.slice(0, 2000)}`
},
})
}
fetch requests any http/https URL, returning the status code and text (truncated to 2000 characters). Note that body is only passed when it has a value, to avoid sending an empty body with GET requests.
import { readdir, readFile } from 'node:fs/promises'
import { join, extname } from 'node:path'
import type { Context } from 'cordis'
const TEXT_EXT = new Set([
'.ts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.md', '.txt', '.yml', '.yaml', '.html', '.css',
])
async function searchFiles(dir: string, keyword: string, depth = 3): Promise<string[]> {
if (depth < 0) return []
const results: string[] = []
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
const full = join(dir, entry.name)
if (entry.isDirectory()) {
results.push(...(await searchFiles(full, keyword, depth - 1)))
} else if (TEXT_EXT.has(extname(entry.name))) {
const content = await readFile(full, 'utf8').catch(() => '')
if (content.includes(keyword)) results.push(full)
}
}
return results
}
export const name = 'mini-search'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register({
name: 'file_search',
description: 'Search for text files containing a keyword in a directory (skips node_modules and hidden directories)',
parameters: {
type: 'object',
properties: {
keyword: { type: 'string', description: 'The keyword to search for' },
dir: { type: 'string', description: 'Starting directory, defaults to current directory' },
},
required: ['keyword'],
},
async execute(args) {
const keyword = String(args.keyword ?? '')
const dir = String(args.dir ?? process.cwd())
try {
const found = await searchFiles(dir, keyword)
return found.length ? found.slice(0, 20).join('\n') : 'No matching files found'
} catch (error) {
return `Search failed: ${(error as Error).message}`
}
},
})
}
file_search recursively searches text files (skipping node_modules and hidden directories, depth limited to three levels), returning a list of matching files. This is the most common tool type for Agents: locate first, then read the file.
Combined, the three tools give the model a basic set of "hands and feet": execute commands, access the network, search code. Tool diversity does not rely on registry design, but on the number of plugins—to add a tool, write another plugin following this template; to disable a tool, remove one line from the assembly list.
Chapter 5 · The Prompt Is Also a Plugin
Extract the Hardcoded Prompt
When writing the Agent next, the easiest thing to hardcode is the prompt. The real Harness's approach is to make the prompt an independent plugin (@deepseek-ai/dsh-system-prompt), because the prompt needs to be modified by other plugins: one adds rules, one adds context, one adds tool descriptions—each managing its own part, none knowing about the others.
The mini version follows suit:
import type { Context } from 'cordis'
export const name = 'mini-prompt'
export const inject = ['tools']
const RULES = `Response rules:
- When a tool call is needed, output only one line of JSON: {"tool": "tool_name", "args": {...}}
- When the task is complete, output only one line of JSON: {"answer": "the final answer to the user"}
- Do not output any content other than JSON.`
export function apply(ctx: Context) {
ctx.on('prompt/build', async (base, next) => {
const downstream = await next()
return `${downstream}\n\nAvailable tools:\n${ctx.tools.describe()}\n\n${RULES}`
})
}
This is prompt.ts. It listens for the prompt/build event—note this is not a regular broadcast, but a waterfall pattern: the listener receives a parameter and a next() continuation, await next() gets the downstream result, then wraps it and returns it upstream. Here, the downstream is the "base prompt" provided by the agent, and mini-prompt appends the tool list and response rules after it.
Want to add another piece of prompt? Add another plugin listening for the same event; no existing code needs to be changed. The prompt transforms from a single string into a pluggable processing chain.
(Another semantic of waterfall is short-circuiting: returning directly without calling next() means the downstream never receives it. For "intercept/veto" events, this is by design; for "append/decorate" events, it's a discipline—always remember to call next().)
Chapter 6 · The Agent Loop
Connecting the Brain to the Body
Now we have an llm that "can chat," three real tools, and a pluggable prompt chain. What's missing is a loop that connects them. This is the core of the Agent: model outputs a tool call → code executes → result is fed back → model continues deciding, until it gives a final answer.
import { Service, type Context } from 'cordis'
import type { ChatMessage } from './llm.js'
declare module 'cordis' {
interface Context {
agent: AgentService
}
interface Events {
'agent/step'(step: AgentStep): void
'prompt/build'(base: string, next: () => Promise<string>): Promise<string>
}
}
export type AgentStep =
| { type: 'tool'; tool: string; args: Record<string, unknown>; result: string }
| { type: 'answer'; answer: string }
const BASE_PROMPT = 'You are an agent that can call tools.'
function parseJson(text: string): Record<string, any> | null {
const stripped = text.trim().replace(/^```(?:json)?\s*|\s*```$/g, '')
try {
return JSON.parse(stripped)
} catch {
return null
}
}
export class AgentService extends Service {
constructor(ctx: Context) {
super(ctx, 'agent')
}
async buildPrompt(): Promise<string> {
return this.ctx.waterfall('prompt/build', BASE_PROMPT, async () => BASE_PROMPT)
}
async run(prompt: string, maxTurns = 5): Promise<string> {
const messages: ChatMessage[] = [
{ role: 'system', content: await this.buildPrompt() },
{ role: 'user', content: prompt },
]
for (let turn = 0; turn < maxTurns; turn++) {
const reply = await this.ctx.llm.chat(messages)
const parsed = parseJson(reply)
if (!parsed) {
return reply
}
if (parsed.answer) {
this.ctx.emit('agent/step', { type: 'answer', answer: parsed.answer })
return parsed.answer
}
if (parsed.tool) {
const result = await this.ctx.tools.execute(parsed.tool, parsed.args)
this.ctx.emit('agent/step', {
type: 'tool',
tool: parsed.tool,
args: parsed.args ?? {},
result,
})
messages.push({ role: 'assistant', content: reply })
messages.push({ role: 'user', content: `Tool result: ${result}` })
continue
}
return reply
}
return 'Maximum loop turns reached, task aborted.'
}
}
export const name = 'mini-agent'
export const inject = ['llm', 'tools']
export function apply(ctx: Context) {
ctx.plugin(AgentService)
}
This is agent.ts. There are four key design points:
Event type declarations (lines 4-12). agent/step is a broadcast event (emit, pure notification); prompt/build is a waterfall event—note its signature carries next: () => Promise<string>, the hallmark of a waterfall listener; the type system uses this to distinguish the mode.
The prompt comes from the event chain (lines 34-36). buildPrompt() calls ctx.waterfall('prompt/build', BASE_PROMPT, async () => BASE_PROMPT): BASE_PROMPT is the initial value for the first listener, and the last parameter is the "fallback value when there are no listeners." The agent doesn't know what the prompt ultimately looks like, nor who is modifying it.
The loop is in run() (lines 38-70). Each turn: call the model → parse JSON → if there's an answer, return it; if there's a tool, execute it, feed the result back as a user message, and enter the next turn. maxTurns = 5 is a safety valve to prevent the model from looping on tools. When parsing fails, the raw text is handed directly to the user—the model not following the protocol is not an error, but a normal state; there must be a fallback.
Each step emits an event (lines 52, 57). Both tool calls and final answers broadcast via ctx.emit('agent/step', ...). The Agent itself doesn't use this, but capabilities like logging, statistics, and leak-prevention filters can be plugged in without modifying the agent code. This is what the previous article meant: intercept with events, invoke with services.
Chapter 7 · Assembly and Execution
Eight Plugins, Assembled into a Harness
import { Context } from 'cordis'
import * as readline from 'node:readline/promises'
import * as llm from './llm.js'
import * as tools from './tools.js'
import * as bash from './bash.js'
import * as fetchTool from './fetch.js'
import * as search from './search.js'
import * as prompt from './prompt.js'
import * as agent from './agent.js'
const tracer = {
name: 'tracer',
apply(ctx: Context) {
ctx.on('agent/step', (step) => {
if (step.type === 'tool') {
console.log(` [tool] ${step.tool}(${JSON.stringify(step.args)}) => ${step.result}`)
}
})
},
}
const app = new Context()
await app.plugin(llm)
await app.plugin(tools)
await app.plugin(bash)
await app.plugin(fetchTool)
await app.plugin(search)
await app.plugin(prompt)
await app.plugin(agent)
await app.plugin(tracer)
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
console.log('mini harness is ready, type exit to quit.')
while (true) {
const line = await rl.question('> ')
if (!line.trim()) continue
if (line === 'exit') break
const reply = await app.agent.run(line)
console.log(reply)
}
rl.close()
This is bin.ts. Lines 11-21, tracer is the eighth plugin, and a live demonstration of "the value of events": it listens for agent/step and prints each tool call as a [tool] log line. It doesn't know the agent, and the agent doesn't know it—their only intersection is that typed event. Remove tracer from the list, and the agent still works; swap it for a listener that writes files or sends notifications, and the agent is none the wiser.
Lines 24-32 are the entire harness assembly manifest: eight plugins mounted in order (the order actually doesn't matter; inject already guarantees the true dependency order). await ensures each plugin finishes loading before continuing.
The rest is a readline interactive loop. Run it:
npm start
Real output (DeepSeek API, asking it "search the current directory for files that mention waterfall"):
mini harness is ready, type exit to quit.
> Search the current directory for files that mention waterfall
[tool] file_search => agent.ts
[tool] bash => 35: return this.ctx.waterfall('prompt/build', ...
In the current directory, agent.ts line 35 mentions waterfall.
The complete chain: the model reads the tool list built by the prompt chain → judges "needs to search" → file_search locates agent.ts → uses bash to pinpoint the line number → synthesizes the two rounds of results to answer. The two [tool] log lines from tracer are the live trace of emit.
What It Is: A Lifecycle-Level Middleware System
At this point, we can answer "what is Cordis really like?"
If you've written Express, its plugins will look familiar—layer after layer of processing. But there is a key difference: Express middleware lives within a single request (request comes in → passes through layers of middleware → response goes out), while Cordis plugins live throughout the entire application lifecycle: load in dependency order at startup, providing services and listeners; clean up in reverse order at disposal, spanning the application's entire lifetime.
Even more interestingly, Cordis has a second layer internally: waterfall events form a next() chain on each dispatch—listeners wrap, modify, short-circuit, until a final value. This layer truly corresponds to the "request-level" experience of Express middleware, like mini-prompt's wrapping of the prompt.
So, strictly speaking:
| Scope | Example | |
|---|---|---|
| Cordis Plugin | Application lifecycle | mini-llm, mini-agent, tracer |
| Cordis Event Chain | Per dispatch | prompt/build wrapping chain |
| Express Middleware | Per request | logging, auth, routing |
Plugins are lifecycle middleware; event chains are dispatch middleware. Understanding these two layers, Cordis's "everything is a plugin" is no longer a slogan, but a concrete engineering model.
What It Still Lacks Compared to the Real Harness
To be honest, it still lacks a lot:
- Conversation memory (
ctx.sessionsin the real Harness): the mini version starts a brand new conversation everyrun(). - Streaming output:
chat()is non-streaming; the real Harness'sctx.llmis a streaming service. - Native tool protocol: the text JSON protocol is simple and transparent, but cannot match the robustness of function calling—in the previous demo, the model occasionally mixed reasoning text with JSON output, or continued exploring open questions until triggering the
maxTurnssafety valve. - Configuration system: the real ecosystem uses Schemastery for runtime validation, plus the loader's
!!jsexpressions. - Security and governance: tool whitelists, command interception, sensitive information filtering—these are independent plugins in the real Harness.
But the skeleton is the same: capabilities broken into plugins, plugins collaborating through services, the collaboration process made visible through events. Once you understand this skeleton, reading the real Harness's source code (the @deepseek-ai/cordis under the vendor/ directory) is no longer reading hieroglyphics.
To continue deeper, the Cordis tutorial (seven chapters) in the repository and the official documentation site are the next stops.