DEV Community

Cover image for Anthropic Says Fold Your Subagents. Let's Build a Tiny Single Agent Loop in TypeScript.
Bobby Hall Jr
Bobby Hall Jr

Posted on

Anthropic Says Fold Your Subagents. Let's Build a Tiny Single Agent Loop in TypeScript.

A lot of agent systems look like org charts.

There's a router at the top. Under it, a refunds agent. A shipping agent. A billing agent.

Each one has its own prompt, its own tools, and its own context window.

It feels organized. It also means every handoff is a summary of a summary.

Every handoff loses something.

This week, Anthropic published Building agents: When to fold subagents into your main agent loop. The opening line is the whole argument: "using fewer subagents in favor of progressively disclosed context and tools can improve quality while reducing cost and latency."

The idea is simple. The agent sees short descriptions of skills and tools up front. The full versions load only when needed.

Anthropic gives three reasons. Shared context produces better answers. Progressive disclosure prevents context bloat. And models are more capable now, starting with Opus 4.5.

One detail stood out. Anthropic says it cut Claude Code's system prompt for more advanced models by over 80%, including cuts from progressively disclosing skills and tools.

It also shipped a migration kit. The kit "does not assume a single loop always wins. It measures."

That distinction matters.

This isn't "subagents are bad." Anthropic still lists cases for them: measurable context bloat, parallel disposable work, and conflicting tools or instructions.

It's "stop splitting work just because the prompt got long."

I've built the other side of this before. My fan-out budget post was about limiting a thousand subagents. This one is about not needing most of them.

So let's build a tiny version.

By the end, you'll run one command:

npx tsx loop.ts
Enter fullscreen mode Exit fullscreen mode

And watch one loop handle a refund by loading only what it needs, then stop at a write gate.

The full code is on GitHub.

Table of Contents

  1. The Problem With Org Chart Agents
  2. Step 1: A Catalog With Two Levels
  3. Step 2: What the Model Sees Up Front
  4. Step 3: The Loader Tools
  5. Step 4: One Loop With Limits
  6. Step 5: Run It
  7. Where It Breaks Down
  8. The Bigger Idea

1. The Problem With Org Chart Agents

Here's the usual shape:

Router
  ├→ refunds agent   (own prompt, own tools, own context)
  ├→ shipping agent  (own prompt, own tools, own context)
  └→ billing agent   (own prompt, own tools, own context)
Enter fullscreen mode Exit fullscreen mode

A customer writes: "My package never came. Can I get a refund?"

That's shipping and refunds. The router picks one. Or both, and then something has to merge two summaries.

The shipping agent knows the package is lost. The refunds agent only knows what the summary said.

Teams split agents like this for a good reason. Putting every policy and every tool schema into one prompt makes it huge.

Progressive disclosure removes that reason.

The prompt doesn't have to be long if it only holds the table of contents.

2. Step 1: A Catalog With Two Levels

// tiny-single-loop: one agent, one context, skills and tools loaded on demand.
// Small model of the pattern Anthropic describes, not Claude's implementation.
// Fake model, fake tools, rough token estimate (chars / 4). No API key. No network.

type Skill = { name: string; description: string; body: string };
type Tool = { name: string; description: string; schema: string; write: boolean; run: (a: string) => string };

// Step 1: a catalog with two levels of detail
const skills: Skill[] = [
  { name: "refunds", description: "Refund or partially refund a customer order.",
    body: "Refund policy: full refund within 30 days. After 30 days, store credit only. ".repeat(12) +
      "Steps: look up the order, check the date, then issue_refund or issue_credit." },
  { name: "shipping", description: "Track, reroute, or reship a package.",
    body: "Carrier rules, address change cutoffs, reship limits. ".repeat(20) },
  { name: "billing", description: "Explain charges, invoices, and failed payments.",
    body: "Invoice formats, proration rules, retry schedules. ".repeat(20) },
];
const tools: Tool[] = [
  { name: "lookup_order", description: "Get an order by id.", schema: "{ id: string }".padEnd(240), write: false,
    run: (id) => `order ${id}: $48.00, placed 12 days ago` },
  { name: "issue_refund", description: "Refund an order to the original payment.", schema: "{ id: string, amount: number }".padEnd(240), write: true,
    run: (id) => `refunded order ${id}` },
  { name: "issue_credit", description: "Give store credit.", schema: "{ id: string, amount: number }".padEnd(240), write: true,
    run: (id) => `credited order ${id}` },
  { name: "track_package", description: "Carrier tracking.", schema: "{ id: string }".padEnd(240), write: false, run: () => "" },
  { name: "reroute_package", description: "Change delivery address.", schema: "{ id: string, address: string }".padEnd(240), write: true, run: () => "" },
];
const tok = (s: string) => Math.ceil(s.length / 4);
Enter fullscreen mode Exit fullscreen mode

Each skill has a one-line description and a long body.

Each tool has a one-line description and a full schema.

The descriptions are cheap. The bodies and schemas are not.

This mirrors Anthropic's Agent Skills format. A SKILL.md starts with a name and description. The body loads only when the skill is relevant.

My bodies are repeated filler so the token math is visible. Real ones would be real policy. Hopefully less repetitive. Probably not.

3. Step 2: What the Model Sees Up Front

// Step 2: what the model sees up front
function upfront(): string {
  return skills.map((s) => `skill ${s.name}: ${s.description}`).join("\n") +
    "\ntools: load_skill(name), tool_search(query)";
}
function everything(): string {
  return skills.map((s) => s.body).join("\n") + tools.map((t) => t.schema).join("\n");
}
Enter fullscreen mode Exit fullscreen mode

Two views of the same catalog.

upfront() is names, descriptions, and two loader tools.

everything() is what you'd paste into a giant system prompt.

The token estimate is rough: characters divided by four. Good enough to compare. Not good enough to bill.

4. Step 3: The Loader Tools

// Step 3: the two loader tools
type Ctx = { text: string; loaded: Set<string>; visible: Set<string>; log: string[] };
function loadSkill(c: Ctx, name: string) {
  const s = skills.find((x) => x.name === name);
  if (!s || c.loaded.has(name)) return;
  c.loaded.add(name);
  c.text += "\n" + s.body;
  c.log.push(`load_skill(${name})  +${tok(s.body)} tok`);
}
function toolSearch(c: Ctx, query: string) {
  const hits = tools.filter((t) => query.split(" ").some((w) => t.name.includes(w)));
  for (const t of hits) { c.visible.add(t.name); c.text += "\n" + t.schema; }
  c.log.push(`tool_search("${query}")  -> ${hits.map((t) => t.name).join(", ")}  +${hits.length * tok(hits[0]?.schema ?? "")} tok`);
}
Enter fullscreen mode Exit fullscreen mode

Two tools do the disclosing.

load_skill reads one skill body into context, once.

tool_search finds matching tools and adds their schemas. Only visible tools can be called.

Two levels of detail

The model decides what to load. The harness just keeps track of what's in context.

Load the chapter when you need it, not the whole manual.

5. Step 4: One Loop With Limits

// Step 4: one loop, with limits in the harness
type Limits = { maxTurns: number; approvedWrites: Set<string> };
function call(c: Ctx, lim: Limits, name: string, arg: string): string {
  const t = tools.find((x) => x.name === name);
  if (!t || !c.visible.has(name)) return `ERROR ${name} not loaded`;
  if (t.write && !lim.approvedWrites.has(name)) return `GATE ${name} needs approval`;
  return t.run(arg);
}
// A fake model: a plan of steps it would choose, one batch per turn.
const plan: Array<(c: Ctx, lim: Limits) => string | null> = [
  (c) => { loadSkill(c, "refunds"); return null; },
  (c) => { toolSearch(c, "order refund credit"); return null; },
  (c, l) => call(c, l, "lookup_order", "1042"),
  (c, l) => call(c, l, "issue_refund", "1042"),
];
function run(task: string, lim: Limits) {
  const c: Ctx = { text: upfront() + "\n" + task, loaded: new Set(), visible: new Set(), log: [] };
  let turn = 0;
  for (const step of plan) {
    if (++turn > lim.maxTurns) { c.log.push("STOP turn budget"); break; }
    const out = step(c, lim);
    if (out) c.log.push(out);
    if (out?.startsWith("GATE")) break;
  }
  return { c, turn };
}
Enter fullscreen mode Exit fullscreen mode

This is the shape Anthropic suggests. One model, one context window, an open-ended tool loop. The limits live in the harness: "turn and cost budgets, write gates."

My call function enforces two of them.

A tool must be loaded before it can run. No calling a schema you've never seen.

A write tool needs approval. Reads run freely. Refunds don't.

The fake model is just a list of steps. A real model would choose them each turn and revise its plan as results come in. Anthropic says that's the main change: "the model that writes the plan is also the one carrying it out."

One loop, limits in the harness

6. Step 5: Run It

// Step 5: run it
console.log("tiny-single-loop demo (fake model, fake tools)\n");
console.log(`all skills + tools up front:  ~${tok(everything())} tok`);
console.log(`names + descriptions only:    ~${tok(upfront())} tok\n`);
const task = "Customer wants a refund for order 1042.";
for (const [label, lim] of [
  ["run 1 (no write approval)", { maxTurns: 6, approvedWrites: new Set<string>() }],
  ["run 2 (refund approved)", { maxTurns: 6, approvedWrites: new Set(["issue_refund"]) }],
  ["run 3 (turn budget 2)", { maxTurns: 2, approvedWrites: new Set(["issue_refund"]) }],
] as const) {
  const { c } = run(task, lim);
  console.log(label);
  for (const l of c.log) console.log("  " + l);
  console.log(`  context at end: ~${tok(c.text)} tok, skills loaded: ${[...c.loaded].join(", ") || "none"}\n`);
}
Enter fullscreen mode Exit fullscreen mode

Run it:

npm install
npx tsx loop.ts
Enter fullscreen mode Exit fullscreen mode

Output:

tiny-single-loop demo (fake model, fake tools)

all skills + tools up front:  ~1077 tok
names + descriptions only:    ~55 tok

run 1 (no write approval)
  load_skill(refunds)  +250 tok
  tool_search("order refund credit")  -> lookup_order, issue_refund, issue_credit  +180 tok
  order 1042: $48.00, placed 12 days ago
  GATE issue_refund needs approval
  context at end: ~496 tok, skills loaded: refunds

run 2 (refund approved)
  load_skill(refunds)  +250 tok
  tool_search("order refund credit")  -> lookup_order, issue_refund, issue_credit  +180 tok
  order 1042: $48.00, placed 12 days ago
  refunded order 1042
  context at end: ~496 tok, skills loaded: refunds

run 3 (turn budget 2)
  load_skill(refunds)  +250 tok
  tool_search("order refund credit")  -> lookup_order, issue_refund, issue_credit  +180 tok
  STOP turn budget
  context at end: ~496 tok, skills loaded: refunds
Enter fullscreen mode Exit fullscreen mode

Read it top to bottom.

Putting every skill and tool up front costs about 1,077 tokens in this toy. Names and descriptions cost about 55.

Run 1 loads the refunds skill and finds three matching tools. It looks up the order. Then it hits the write gate on issue_refund and stops.

Run 2 is the same, with the refund approved. It goes through.

Run 3 has a turn budget of two. It stops before it touches the order.

In every run, shipping and billing never load. They cost nothing because nobody needed them.

The context ends around 496 tokens instead of starting at 1,077. These are toy numbers from toy bodies. The direction is the point, not the ratio.

7. Where It Breaks Down

This is a small model of the idea. It is not how Claude works, and it is not Anthropic's kit. The skills, tools, order, and token numbers are example inputs.

A few things it skips:

  • Measuring. Anthropic's kit scores every change on the same eval before and after. I don't have an eval here. If you fold a subagent, measure first.
  • Search quality. My tool_search matches substrings. A bad search hides the right tool, and the agent never knows it existed.
  • Context still grows. Loading on demand delays bloat. A long session can still fill the window. Compaction still matters.
  • Parallel work. Anthropic still recommends subagents for parallel, disposable work. One loop doesn't replace a thousand-file review.
  • Conflicting instructions. If two skills give opposite rules, one context holds both. Separate agents can keep them apart.
  • Real budgets. My turn budget counts steps. Real ones count cost and time too.

Fewer agents isn't the goal. Fewer lossy handoffs is.

8. The Bigger Idea

Task
  ↓
Up front: skill names + descriptions, loader tools
  ↓
Model loads a skill, searches for tools
  ↓
Harness checks: loaded? write approved? budget left?
  ↓
Tool runs, raw result returns to the same context
  ↓
Model revises the plan, repeats or answers
Enter fullscreen mode Exit fullscreen mode

The model provides the plan.

The descriptions provide the map.

The skills provide the knowledge.

The tool search provides the hands.

The harness provides the limits.

The shared context provides the memory between steps.

For a while, the answer to a hard agent problem was another agent.

Now, at least for a lot of workloads, it might be a shorter prompt and a better index.

One loop that knows where to look beats five that each know a little.


Try Roster

I'm building Roster around this idea: AI employees with real responsibilities, tools, memory, schedules and computer access. One employee that keeps the whole picture beats a relay of handoffs.

If the same follow-ups, handoffs, and waiting loops keep eating your week, give them to an AI employee.

Try Roster →

Top comments (0)