
Splitting Router and Worker Roles in a Node.js Coding Agent: Lessons from dsh-superagent
On this page
Introduction
On 2026-11-01, MarkTechPost published "DeepSeek Open-Sources dsh-superagent, a Router-Worker Coding Agent," and roughly 80 minutes later BigGo Finance framed the same release as "a direct challenge to Anthropic's Claude Code." Two headlines, two snippets, no repo link, no license, no benchmark table, no architecture doc. That was the entire surface I had to work with.
This post is not a tour of DeepSeek's source tree, because I never saw it. It walks through the pattern the headline names: splitting router and worker roles in a Node.js coding agent, with a typed handoff contract, step budgets enforced in code, and a scope gate that diffs the filesystem before it trusts a worker's summary. That split is worth stealing regardless of whether DeepSeek shipped it first. My read: the router-worker split genuinely helps on multi-file work compared to a single agent loop, but the naive version moves context bloat around instead of removing it. If the router ends up holding the worker's transcript, you paid for two models and got one conversation. It only pays off when the boundary is a typed contract with hard budgets, and workers return artifacts rather than reasoning.
I'll be explicit throughout about what the coverage confirms, what is my own reconstruction of the pattern, and what I actually ran.
What the dsh-superagent Coverage Confirms and What It Leaves Out
Confirmed Details, Publication Date, and Open Questions
| Claim | Status | Basis |
|---|---|---|
| DeepSeek open-sourced a coding agent called dsh-superagent | Confirmed by coverage | MarkTechPost headline, 2026-11-01 |
| The design is described as router-worker | Confirmed, headline-level | MarkTechPost headline |
| Coverage positions it as a direct challenge to Anthropic's Claude Code | Confirmed by coverage | BigGo Finance, 2026-11-01 |
| License, supported models, repo layout, tool schema, benchmark numbers, whether workers run in-process or as subprocesses | Unknown to me | Not present in the seed material |
I have not run dsh-superagent. Everything in the code sections below is my own reference implementation of a router-worker coding agent in Node.js, written to test the pattern's mechanics — not a description of DeepSeek's implementation.
Treat any claim about dsh-superagent's internals that you see quoted without a link to an actual file or commit as unverified. Headline-level coverage tells you a design exists; it does not tell you the tool allowlist, the budget model, or whether the worker shares memory with the router.
If you do have the repo open, the first three things worth checking are: does the router pass file contents or file paths to workers, is the step budget enforced in code or in a prompt, and does a worker's return value include its transcript. Those three answers tell you whether the architecture is doing any work at all.
Why a Single Agent Loop Starts to Hurt
Context Bloat, Tool Sprawl, and Mixed Planning and Execution
A single-loop agent — the shape most of us have used — keeps planning, acting, and observing in one growing conversation. Three things compound.
Context bloat. Every turn resends the entire transcript. Cost is quadratic in step count, but the correctness problem is worse than the bill: past roughly 40–60k tokens of accumulated tool output, models start repeating failed actions, because the earlier failure is buried under forty thousand tokens of ls output. My observation from debugging my own agent, not a measured study.
Tool sprawl. Register 25 tools and selection accuracy falls off a cliff. A model choosing between read_file, read_file_range, grep, ripgrep_search, glob, and find_files will pick the wrong one, and each wrong pick burns a turn. Constrain the surface to three tools and the flailing mostly stops.
Mixed planning and execution. The same context that decides "should I touch the lockfile" is simultaneously holding a 400-line diff of src/config.ts. Those are different jobs, they want different prompts, and they want different models — a cheap one for routing, a strong one for the edit.
The Router Role: Decomposition, Routing, and Budget Policy
The router does four things — decomposition, tool allowlisting, budgets, acceptance criteria — and it never edits a file.
Route Selection and Budget Enforcement
Before spending a single token, check whether a rule decides the route. Most coding-agent requests that show up in a real workflow are already classified by something cheaper than a model call: the failing test name, the file path in the stack trace, or an explicit command.
type Route = { taskId: string; goal: string; tools: string[]; paths: string[] };
const RULES: Array<{ match: (input: string) => boolean; route: Route }> = [
{
match: (s) => /\.test\.(ts|tsx)$/.test(s),
route: { taskId: "tests", goal: "make the named test pass", tools: ["read_file", "run_tests"], paths: ["**/*.test.ts"] },
},
{
match: (s) => s.startsWith("/lint"),
route: { taskId: "lint", goal: "fix lint errors without behavior change", tools: ["read_file", "write_file"], paths: ["src/**"] },
},
];
export function preRoute(input: string): Route | null {
return RULES.find((r) => r.match(input))?.route ?? null;
}
Only escalate to the model when preRoute returns null. In my agent this cut routing calls by roughly two thirds on repetitive work, and — more importantly — made routing deterministic and testable.
Budgets have to live in code. A prompt that says "use at most 8 steps" is a suggestion; a counter is a guarantee.
Keeping the Router Cheap and Deterministic Where Possible
The router should see the user goal, a file tree summary, and the results of prior tasks. It should not see file contents, or tool output beyond a short tail. That's the whole point of the split — the expensive context sits in the worker, which throws it away on exit.
The Worker Role: A Bounded Execution Loop
Constraining the Tool Surface to One Task
A worker gets one task, the subset of tools that task needs, and a step cap. Filtering happens at registration time, not in the prompt:
const ALL_TOOLS = { read_file, write_file, run_tests, git_diff, install_deps };
function toolsFor(task: TaskEnvelope) {
const allowed = task.allowlist.tools;
return Object.fromEntries(
allowed.map((name) => {
if (!(name in ALL_TOOLS)) throw new Error("unknown tool: " + name);
return [name, ALL_TOOLS[name as keyof typeof ALL_TOOLS]];
}),
);
}
A worker that asks for a bad tool name throws instead of silently receiving the full toolset. That one line is how you avoid the most common regression in agent code: an allowlist that quietly stops being enforced because someone added a fallback.
Returning Artifacts Instead of Transcripts
This is the decision that determines whether the split was worth it. The worker returns paths, hashes, an exit code, and a short summary — never its reasoning trace. The router does not need to know that the worker tried three approaches before succeeding; it needs to know that src/config.ts changed and npm test -- config exited 0.
Trade-off: you lose some debuggability at the router level. In exchange, the router's context stays flat across a twenty-task plan instead of growing linearly. Keep the full trace in the worker's own log, keyed by trace ID, and you get both.
Designing the Handoff Contract in TypeScript
The Task Envelope
export const TaskEnvelope = z
.object({
traceId: z.string().uuid(),
taskId: z.string(),
goal: z.string().max(500), // one imperative sentence
allowlist: z.object({
tools: z.array(z.enum(["read_file", "write_file", "run_tests", "git_diff"])).min(1),
paths: z.array(z.string()).min(1), // globs, relative to repo root
}),
acceptance: z.object({
command: z.string(),
expectExitCode: z.number().int().default(0),
}),
budget: z.object({
maxSteps: z.number().int().max(30).default(12),
maxOutputBytes: z.number().int().default(64_000),
}),
context: z.object({
files: z.array(z.string()).default([]), // pointers, not contents
notes: z.string().max(2_000).optional(),
}),
})
.strict();
export type TaskEnvelope = z.infer<typeof TaskEnvelope>;
The .strict() matters. Unknown keys in a handoff envelope usually mean two versions of the router are running at once, and silent acceptance hides that.
The Result Envelope
export const ResultEnvelope = z
.object({
taskId: z.string(),
status: z.enum(["ok", "failed", "blocked", "budget_exhausted"]),
artifacts: z.array(
z.object({
path: z.string(),
change: z.enum(["created", "modified", "deleted"]),
sha256: z.string().length(64),
bytes: z.number().int(),
}),
),
verification: z
.object({
command: z.string(),
exitCode: z.number().int(),
stdoutTail: z.string().max(4_000),
})
.nullable(),
summary: z.string().max(400), // for the router, not for humans
steps: z.number().int(),
})
.strict();
The 400-character cap on summary is deliberate. It forces the worker to report state, not narrative. blocked is a distinct status from failed because "I can't proceed without a decision" needs to reach the router as a question, not a retry.
Wiring Router and Worker in Node.js
In-Process Calls vs Child Process Boundaries
| Concern | In-process | Child process |
|---|---|---|
| Startup cost | ~0 | 40–120 ms per worker |
| Isolation | None — a throwing worker can crash the router | Per-worker crash containment |
| Secret exposure | Worker inherits full process.env | Explicit env allowlist |
| Filesystem scope | Whatever the router can touch | Can chdir into a worktree |
| Debugging | Single stack trace | Needs trace IDs to stitch logs |
| Serialization | Shared objects | JSON over IPC |
My take: start in-process, move to child processes the moment a worker can run a shell command. Once you're executing commands a model generated, inheriting process.env is a real leak — AWS_SECRET_ACCESS_KEY in a child's environment is one printenv away from a log file.
A Minimal Runnable Skeleton with Shown Output
This file runs as-is with node router-worker.mjs on Node 22. No API key: the model call is a deterministic stub so you can see the control flow.
import { randomUUID } from "node:crypto";
const traceId = randomUUID().slice(0, 8);
const TOTAL_STEP_BUDGET = 24;
let stepsUsed = 0;
// 1. Router: decompose the goal into bounded tasks.
function route(goal) {
if (!goal.includes("parseConfig")) throw new Error("no route matched: " + goal);
return [
{
taskId: "t1",
goal: "add a null check to parseConfig",
tools: ["read_file", "write_file"],
paths: ["src/config.ts"],
budget: { maxSteps: 8 },
},
{
taskId: "t2",
goal: "verify the config test suite passes",
tools: ["run_tests"],
paths: ["test/config.test.ts"],
budget: { maxSteps: 4 },
},
];
}
// 2. Worker: one task, a few tools, a hard step cap.
async function runWorker(task) {
const artifacts = [];
let verification = null;
let steps = 0;
while (true) {
if (steps >= task.budget.maxSteps) {
return { taskId: task.taskId, status: "budget_exhausted", steps, artifacts, verification };
}
const action = nextAction(task, steps);
if (action.type === "finish") {
return { taskId: task.taskId, status: "ok", steps, artifacts, verification };
}
if (!task.tools.includes(action.tool)) {
return { taskId: task.taskId, status: "blocked", steps, artifacts, verification };
}
steps += 1;
if (action.tool === "write_file") {
artifacts.push({ path: action.path, change: "modified", bytes: 412 });
}
if (action.tool === "run_tests") {
verification = { command: "npm test -- config", exitCode: 0 };
}
}
}
// Deterministic stand-in for the model. Swap for your provider call.
function nextAction(task, step) {
const script = {
t1: [
{ type: "tool", tool: "read_file", path: "src/config.ts" },
{ type: "tool", tool: "write_file", path: "src/config.ts" },
{ type: "finish" },
],
t2: [{ type: "tool", tool: "run_tests" }, { type: "finish" }],
};
return script[task.taskId][step] ?? { type: "finish" };
}
// 3. Router sequences tasks and enforces the global budget.
const tasks = route("fix parseConfig null handling");
console.log("[" + traceId + "] router plan tasks=" + tasks.length + " stepBudget=" + TOTAL_STEP_BUDGET);
const results = [];
for (const task of tasks) {
if (stepsUsed >= TOTAL_STEP_BUDGET) {
results.push({ taskId: task.taskId, status: "skipped" });
continue;
}
const result = await runWorker(task);
stepsUsed += result.steps;
results.push(result);
console.log(
"[" + traceId + "] worker " + result.taskId +
" status=" + result.status +
" steps=" + result.steps +
" artifacts=" + result.artifacts.length +
" verify=" + (result.verification ? "exit " + result.verification.exitCode : "none"),
);
}
const ok = results.filter((r) => r.status === "ok").length;
console.log(
"[" + traceId + "] router done ok=" + ok +
" failed=" + (results.length - ok) +
" steps=" + stepsUsed + "/" + TOTAL_STEP_BUDGET,
);Observed output (Node v22.11.0, x86_64 Linux; the trace ID changes per run):
[4b8e2f1a] router plan tasks=2 stepBudget=24
[4b8e2f1a] worker t1 status=ok steps=2 artifacts=1 verify=none
[4b8e2f1a] worker t2 status=ok steps=1 artifacts=0 verify=exit 0
[4b8e2f1a] router done ok=2 failed=0 steps=3/24
Note what is not printed: no file contents, no tool output, no reasoning. The router's entire view of task t1 is "status=ok, steps=2, artifacts=1."
Router-Worker vs Single-Loop Agents Like Claude Code
Claude Code, as I use it, is a single conversation with tools: one growing context, model-driven tool selection, and a human in the loop. That shape is excellent for exploratory work — you don't know the plan until you've looked at the code. Router-worker is the opposite bet: the plan exists before execution, and execution is cheap because it's bounded.
Latency, Cost, and Context Trade-offs in Practice
Here is token arithmetic on stated assumptions — not a benchmark. Single loop: 6,000-token base context, 12 turns, each adding 800 tokens of tool output, each turn resending everything. Router-worker: same base for the router, 1,500-token base per worker.
| Run shape | Model calls | Input tokens sent |
|---|---|---|
| Single loop | 12 | ~124,800 |
| Router (plan + synthesis) | 3 | ~14,000 |
| Worker t1 (1.5k base, 2 tool steps) | 3 | ~6,900 |
| Worker t2 (1.5k base, 1 tool step) | 2 | ~3,800 |
| Router-worker total | 8 | ~24,700 |
Roughly 5× less input volume for the same two-task plan, because no participant ever resends a transcript it doesn't need. That advantage widens with plan length: a single loop is quadratic, the router-worker sum is close to linear.
The trade is wall-clock latency on small tasks. Router-worker adds a routing call, an envelope serialization, and a synthesis pass. For a one-line fix, that overhead dominates and the single loop wins outright. My rule: if the plan fits in one tool call, do not route it.
Failure Modes Worth Testing Before You Trust It
The Telephone Game Problem at the Boundary
The router writes a task description; the worker interprets it; the worker writes a summary; the router acts on the summary. Two lossy natural-language hops sit between intent and verification. The classic failure: the worker edits src/env.ts outside its allowlist, then summarizes "updated config parsing," and the router never learns about the extra file.
Fix it with code, not better prose. Diff the actual repo state against the allowlist before you accept the result:
import { execFileSync } from "node:child_process";
export function checkScope(allowedGlobs: string[]): string[] {
const out = execFileSync("git", ["diff", "--name-only", "HEAD"], { encoding: "utf8" });
const changed = out.trim().split("
").filter(Boolean);
return changed.filter(
(path) => !allowedGlobs.some((glob) => minimatch(path, glob, { dot: true })),
);
}execFileSync with an argument array — no shell — is the safe form here. If checkScope returns a non-empty array, mark the task failed regardless of what the summary claims. The filesystem is the source of truth, not the model's self-report.
Worker Scope Creep, Unbounded Retries, and Secret Leakage
| Failure | Symptom | Structural fix |
|---|---|---|
| Scope creep | Worker edits files outside allowlist.paths | checkScope gate before accepting result |
| Unbounded retries | Router re-dispatches the same failing task forever | Per-task attempt cap (2), then blocked |
| Budget laundering | Sub-tasks each within budget, total unbounded | Global TOTAL_STEP_BUDGET in the router loop |
| Secret leakage | Worker runs printenv and logs it | Explicit env allowlist for child processes |
| Silent tool widening | Allowlist silently falls back to full toolset | Throw on unknown tool name |
The env allowlist, if you go the subprocess route:
const ALLOWED_ENV = new Set(["PATH", "HOME", "NODE_ENV", "CI", "TMPDIR"]);
const env = Object.fromEntries(
Object.entries(process.env).filter(([k]) => ALLOWED_ENV.has(k)),
);
Anything not on that list simply isn't there. Cheap, and it removes a whole class of "the agent helpfully printed my token" incidents.
Observability: Trace IDs, Replayable Envelopes, and Per-Role Evals
Two rules make a router-worker system debuggable.
Append every envelope to JSONL, keyed by trace ID. Not just the router's view — the worker's too, so a trace ID reconstructs the whole fan-out.
await appendFile(
"runs/agent.jsonl",
JSON.stringify({ ts: Date.now(), traceId, role: "worker", kind: "result", envelope }) + "\n",
);
Because envelopes are validated, non-nested, and self-contained, you can replay a run: feed the recorded ResultEnvelope back into the router and see whether it makes the same decision. That's nearly impossible with a monolithic transcript, where "replaying" means re-sending 60k tokens and hoping.
Evaluate the roles separately. One number can't diagnose this system.
| Metric | Role | What a bad score means |
|---|---|---|
| Routing accuracy | Router | Decomposition is wrong; workers can't win |
| Tasks per plan | Router | Over-decomposition; paying coordination tax |
| Task success rate | Worker | Prompt or tool surface is wrong |
budget_exhausted rate | Worker | Caps too tight, or the task is underspecified |
| Scope violations | Gate | Allowlist too loose, or worker prompt drifting |
If routing accuracy is 95% and task success is 40%, no amount of worker prompt tuning will save you. If it's the reverse, your router is producing incoherent plans and the workers are heroically compensating.
What Is Actually Reusable in Your Own TypeScript Workflow
Whatever dsh-superagent's source turns out to contain, these pieces port directly into your own Node.js coding agent:
- The envelope schemas.
TaskEnvelopeandResultEnvelopewith.strict()parsing at every boundary. This alone forced me to stop passing prose between components. - Budgets as code. A counter in the loop, not a sentence in the prompt.
- The deterministic pre-router. Rules first, model second. It's faster, cheaper, and unit-testable.
- Artifact-returning workers. Paths, hashes, exit codes, ≤400-char summary. Keep the trace in a log, not in the router.
- The scope gate.
git diff --name-onlyagainst the allowlist before accepting any result. - JSONL envelopes + replay. Turns debugging from archaeology into arithmetic.
What I would not copy wholesale: a full framework. The skeleton above is about 90 lines and covers the pattern. Adopt the contract, not the dependency.
Conclusion
The dsh-superagent coverage is a vendor-rivalry story — DeepSeek open-sourcing an agent framed against Claude Code. The useful part is narrower: split the loop into a policy layer that sees plans and an execution layer that sees files.
That split works, and the ~5× context reduction from my token arithmetic is the kind of win that compounds over a long session. But it only holds if the boundary stays thin. The moment a router absorbs worker transcripts, or a worker returns narrative instead of artifacts, you've rebuilt the single loop with an extra hop and an extra model bill. Build the envelopes first, put the budget in code, diff the filesystem before trusting the summary — and keep the single loop for anything that fits in one tool call.
Further Reading
- MarkTechPost, "DeepSeek Open-Sources dsh-superagent, a Router-Worker Coding Agent," 2026-11-01 — coverage via Google News
- BigGo Finance, "DeepSeek Unveils Open-Source 'Super-Agent' Coding Tool in Direct Challenge to Anthropic's Claude Code," 2026-11-01 — coverage via Google News
- Anthropic, Claude Code documentation — docs.anthropic.com
- Node.js
child_processdocumentation, includingexecFile/execFileSyncargument-array semantics — nodejs.org - Zod schema validation documentation, including
.strict()— zod.dev
More posts

Benchmarking tsrs Against tsc: Testing an AI-Generated Rust TypeScript Compiler Before It Hits Your Build Pipeline
A deep, hands-on benchmark of tsrs, the AI-generated Rust port of TypeScript, comparing its performance and compatibility against tsc and showing how to validate it before adopting it in a build pipeline.

Verifying a Patched Brotli Decoder in Node.js and .NET Without Trusting the Changelog
A hands-on walkthrough for confirming the Brotli decoder fix in Node.js and .NET by building reproducible crash and differential tests instead of relying on release notes and changelogs.

Scoring Partial Progress in Agent Evals: A GDPx-Inspired Harness in JavaScript
Build a JavaScript harness that credits partial progress on long-horizon agent tasks, inspired by SemiAnalysis's GDPx benchmark where the best frontier model tops out at just 12%.
Loading comments…