Splitting Router and Worker Roles in a Node.js Coding Agent: Lessons from dsh-superagent

Splitting Router and Worker Roles in a Node.js Coding Agent: Lessons from dsh-superagent

pr0h0••15 min read•coding
AI Usage (87%)
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

ClaimStatusBasis
DeepSeek open-sourced a coding agent called dsh-superagentConfirmed by coverageMarkTechPost headline, 2026-11-01
The design is described as router-workerConfirmed, headline-levelMarkTechPost headline
Coverage positions it as a direct challenge to Anthropic's Claude CodeConfirmed by coverageBigGo Finance, 2026-11-01
License, supported models, repo layout, tool schema, benchmark numbers, whether workers run in-process or as subprocessesUnknown to meNot 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

ConcernIn-processChild process
Startup cost~040–120 ms per worker
IsolationNone — a throwing worker can crash the routerPer-worker crash containment
Secret exposureWorker inherits full process.envExplicit env allowlist
Filesystem scopeWhatever the router can touchCan chdir into a worktree
DebuggingSingle stack traceNeeds trace IDs to stitch logs
SerializationShared objectsJSON 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.

router-worker.mjs
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 shapeModel callsInput tokens sent
Single loop12~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 total8~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:

verify-allowlist.ts
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

FailureSymptomStructural fix
Scope creepWorker edits files outside allowlist.pathscheckScope gate before accepting result
Unbounded retriesRouter re-dispatches the same failing task foreverPer-task attempt cap (2), then blocked
Budget launderingSub-tasks each within budget, total unboundedGlobal TOTAL_STEP_BUDGET in the router loop
Secret leakageWorker runs printenv and logs itExplicit env allowlist for child processes
Silent tool wideningAllowlist silently falls back to full toolsetThrow 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.

MetricRoleWhat a bad score means
Routing accuracyRouterDecomposition is wrong; workers can't win
Tasks per planRouterOver-decomposition; paying coordination tax
Task success rateWorkerPrompt or tool surface is wrong
budget_exhausted rateWorkerCaps too tight, or the task is underspecified
Scope violationsGateAllowlist 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:

  1. The envelope schemas. TaskEnvelope and ResultEnvelope with .strict() parsing at every boundary. This alone forced me to stop passing prose between components.
  2. Budgets as code. A counter in the loop, not a sentence in the prompt.
  3. The deterministic pre-router. Rules first, model second. It's faster, cheaper, and unit-testable.
  4. Artifact-returning workers. Paths, hashes, exit codes, ≤400-char summary. Keep the trace in a log, not in the router.
  5. The scope gate. git diff --name-only against the allowlist before accepting any result.
  6. 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_process documentation, including execFile/execFileSync argument-array semantics — nodejs.org
  • Zod schema validation documentation, including .strict() — zod.dev

Share this post

More posts

Loading comments…