Skip to content

Build a Claude-Code-style coding agent

You want the thing you already use every day: an agent that is pointed at a repository, reads it, forms a plan, edits files, runs the tests, and comes back with a diff — while a human keeps a hand on the brake.

That agent is not one model call. It is a loop with six things wired around it: an identity, a filesystem tool pack, a way to research a codebase without drowning in context, a spend ceiling, a way to survive a session longer than the context window, and an approval gate on the commands that can hurt you. toolnexus ships all six as first-class seams. This page wires them together end to end.

Piece Shipped seam Why it is in the build
An identity that survives edits soulFileAGENTS.md The operating rules live in a file in the repo, reviewable in a PR — not in a string literal.
Filesystem + shell hands the 10 built-ins read/write/edit/bash/grep/glob are what turn a chat model into something that can change a repo.
Codebase research without context rot an explore sub-agent The child burns its own window on 40 file reads and returns one paragraph.
A spend ceiling Budget + status: "incomplete" A runaway loop that reads the whole monorepo stops loudly instead of billing you.
Sessions longer than the window compactor on beforeLLM Coding agents are the highest tool-volume agents there are.
A brake on destructive commands pending(...) + waitFor rm -rf, git push --force, terraform apply — a human says yes first.

Everything below is additive over the classic API. No piece requires the others.

The agent’s operating manual is a file in the repository, not a string in your source. That matters for a real deployment: the rules get reviewed in a pull request, diffed when behavior changes, and read by humans who never open your code. soulFile reads it at registry-build time and makes it the agent’s system prompt.

AGENTS.md
You are a senior engineer working directly in this repository.
## How you work
1. UNDERSTAND FIRST. Before editing anything, delegate to `explore` with a specific
question ("where is the retry budget applied to MCP reconnects?"). Do not read
twenty files yourself — that is what `explore` is for, and its context is not yours.
2. Make the smallest change that solves the problem. No drive-by refactors, no style
fixes in files you were not asked to touch.
3. Prefer `edit` (exact-string replace) over `write` (whole-file overwrite). `write` on
an existing file loses anything you did not know was there.
4. After every change, run the narrowest useful test command and read the output. A
change you have not run is a guess.
5. If you cannot finish, say exactly what is done, what is not, and what you tried.
## Boundaries
- Never `git push`, never force-push, never touch `.env` or anything under `vault/`.
- Destructive shell commands will pause for human approval. That is expected — explain
in the command's `description` why you need it, because the human reads that line.
- Secrets are use-only: reference `$VAR`, never print or echo a value.

toolnexus ships opencode’s ten built-ins with identical names and input schemas in every port. Six of them are the coding agent’s hands:

Tool What it buys the agent
read A file, or a line window of it (offset/limit) — so a 6,000-line file costs 40 lines of context.
grep Regex over file contents under a path, include glob filter, capped at limit (default 100). The agent’s search.
glob Find files by pattern. How it discovers structure without ls-ing the world.
edit Exact-string replace. Non-unique oldString without replaceAll is a loud isError — the model cannot silently corrupt the wrong occurrence.
write Create or overwrite, parent dirs included. Use for new files.
bash One shell command in a workdir, combined stdout+stderr, non-zero exit ⇒ isError with the exit code, timeout kills the child. This is how it runs your tests.

The other four (webfetch, question, apply_patch, todowrite) are all useful here too — apply_patch for multi-file atomic edits, todowrite for the agent’s own plan, question to ask you something structured (it suspends, it does not block).

3. Research without context rot — the explore sub-agent

Section titled “3. Research without context rot — the explore sub-agent”

Here is the failure mode that separates a real coding agent from a demo: answering “where does this behavior come from?” costs thirty tool calls and forty file excerpts. If the main agent does that itself, its context is now 80% stale file contents, and it has to keep re-reading them through every subsequent turn. Quality degrades, cost climbs, and it eventually overflows.

Delegation fixes it structurally. explore runs on a fresh transcript, spends its own window on those thirty calls, and returns one tool message to the parent: the answer. The parent’s context grows by a paragraph, not by a codebase.

The second thing delegation buys is scoping. explore is given a read-only tool view — no bash, no write, no edit, no apply_patch. It is not asked to behave; it is structurally incapable of writing. Scoping is the security model, and a research agent that cannot mutate the tree is a research agent you can point at an untrusted prompt.

import { createToolkit, agents } from "toolnexus"
// A READ-ONLY view: drop everything that mutates or executes.
const readOnly = await createToolkit({
builtins: { tools: { bash: false, write: false, edit: false, apply_patch: false } },
})
const explore = agents.agent("explore", {
does: "Answers a specific question about this codebase. Give it ONE question, not a task. " +
"Read-only: it cannot edit files or run commands.",
uses: { tools: readOnly.tools() }, // read, grep, glob, webfetch, question, todowrite
soul: "Answer the question and nothing else. Cite file:line for every claim. " +
"If the answer is not in the code, say so — never guess.",
budget: { maxTurns: 20, maxTokens: 60_000 },
})

4. Budgets — a runaway cannot spend unbounded

Section titled “4. Budgets — a runaway cannot spend unbounded”

An agent with bash and a loop is a machine for spending money. Budget bounds it in seven dimensions, and the bound is hierarchical and live: carved at spawn as min(own, parent remaining), then re-checked against the whole ancestor chain before every turn and every spawn — so a sibling’s spend counts against you.

Dimension Bounds
maxTurns LLM round trips on this handle (never reset — a resume grows it).
maxTokens Tokens for this handle and its whole subtree.
maxToolCalls Total tool invocations in the subtree.
maxWallMs Wall-clock deadline (on the runtime’s injectable clock).
maxChildren How many sub-agents may be spawned.
maxConcurrent Running children per parent — the fan-out gate.
maxDepth How deep delegation may nest.

When a limit stops a run, the result is status: "incomplete" — never a silent "done", never a crash. Partial work and the transcript are preserved, so you can inspect what it got through before the ceiling. The status vocabulary is closed and byte-identical in all six ports:

"done" | "pending" | "incomplete" | "interrupted" | "closed" | "timeout" | "error"

5. Surviving a long session — compaction

Section titled “5. Surviving a long session — compaction”

Coding agents have the worst context profile of any agent type: dozens of file reads, long test output, stack traces. Even with explore absorbing the research, a serious session overflows.

compactor(opts) returns a beforeLLM hook: once the transcript estimate crosses maxTokens, it summarizes the older body into one message and keeps a recent tail verbatim. It adds no loop behavior — it is a pure messages → messages rewrite on a seam that already existed, and below maxTokens it is a no-op byte-identical to running without it.

Two invariants worth knowing before you tune it:

  • Tool-pair safety. The retained tail always begins at a user turn, so a tool result is never orphaned from the assistant message carrying its tool_call_id. Providers reject that shape; the compactor cannot produce it.
  • The soul survives. A leading system message is preserved verbatim. Only the body between it and the tail is summarized — your AGENTS.md rules never get summarized away.

Because compaction rides the client loop’s hook and the agent runtime owns its own clients, wire it on the client when you run a coding session through client.ask(...) directly. The full assembly below shows the agent path; see persona agents → compaction for the client-loop wiring in all six languages.

6. The brake — human approval on dangerous bash

Section titled “6. The brake — human approval on dangerous bash”

The built-in bash runs what it is given. For anything touching production, you want a human in front of the destructive subset — and you want the safe subset (running tests, git status) to stay fast and unattended. Prompting for it is not a control; the model can ignore prose.

The control is a tool that suspends. You wrap the built-in bash in a tool of the same name and schema that classifies the command: safe ones pass straight through, dangerous ones return pending(...). The loop halts, your waitFor asks the human, and the tool is re-executed with ctx.answer carrying the verdict.

This is suspension with nothing special-cased — the same primitive that handles OAuth and structured questions.

import { createToolkit, defineTool, pending, type Tool } from "toolnexus"
const DANGEROUS = /\b(rm\s+-rf|git\s+push|--force|drop\s+table|terraform\s+apply|kubectl\s+delete)\b/i
/** Same name + schema as the built-in — the model cannot tell it apart. */
function gatedBash(raw: Tool): Tool {
return defineTool({
name: raw.name,
description: raw.description,
inputSchema: raw.inputSchema,
run: async (args, ctx) => {
const command = String(args.command ?? "")
if (!DANGEROUS.test(command)) return raw.execute(args, ctx) // safe: straight through
if (!ctx?.answer) {
// First pass: halt. `data` is plain serializable payload for your UI.
return pending({
kind: "question",
prompt: `Approve destructive command?\n\n ${command}\n\n(${args.description ?? "no reason given"})`,
data: { command, workdir: args.workdir ?? null },
})
}
if (!ctx.answer.ok) return { output: `Operator declined: ${command}`, isError: true }
return raw.execute(args, ctx) // approved: run it
},
})
}
const full = await createToolkit()
const coderTools = full.tools().map((t) => (t.name === "bash" ? gatedBash(t) : t))

waitFor on the agent is the interpreter for its subtree. Give it one and the pause is invisible to the caller — run() blocks on your prompt and returns "done". Omit it and the run returns status: "pending" with the Request, which you persist, post to Slack, and resume days later. Same tool, same gate; you pick by when the human answers, not by what you are asking.

// answer NOW — a terminal, a chat you can await
waitFor: async (req) => ({ id: req.id, ok: await confirmOnTerminal(req.prompt) })
// answer LATER — omit waitFor entirely; run() returns status:"pending" + req, you resume

Nearest interpreter wins, strict one hop: if explore had a waitFor it would resolve its own suspensions; otherwise the request rides up to the coder’s, then to the root. Parked levels burn zero tokens while they wait.

Everything above, wired. The coder owns the full (gated) pack and one delegate; explore is read-only; budgets bound the tree; the approval gate is live.

import { createToolkit, agents } from "toolnexus"
const llm = { baseUrl: "https://openrouter.ai/api/v1", style: "openai" as const, model: "anthropic/claude-sonnet-4" }
// --- tools -----------------------------------------------------------------
const readOnly = await createToolkit({
builtins: { tools: { bash: false, write: false, edit: false, apply_patch: false } },
})
const full = await createToolkit()
const coderTools = full.tools().map((t) => (t.name === "bash" ? gatedBash(t) : t))
// --- the team --------------------------------------------------------------
const explore = agents.agent("explore", {
does: "Answers ONE specific question about this codebase. Read-only: cannot edit or run commands.",
uses: { tools: readOnly.tools() },
soul: "Answer the question and nothing else. Cite file:line. Never guess.",
budget: { maxTurns: 20, maxTokens: 60_000 },
})
const coder = agents.agent("coder", {
does: "Implements a change in this repository",
soulFile: "./AGENTS.md",
uses: { tools: coderTools },
team: [explore],
budget: { maxTurns: 60, maxTokens: 400_000, maxChildren: 8, maxDepth: 2 },
waitFor: async (req) => ({ id: req.id, ok: await confirmOnTerminal(req.prompt) }),
})
// --- run -------------------------------------------------------------------
const r = await coder.run("The MCP stdio client leaks a child process on reconnect. Find it and fix it.", { llm })
console.log(r.status) // "done" | "incomplete" | "pending" | "error"
console.log(r.text) // the final report
console.log(r.totalTokens) // WHOLE-TREE tokens: coder + every explore call
if (r.status === "incomplete") console.warn("stopped by a budget:", r.text)

It is the engine. The loop, the tool pack, delegation with real context isolation, hierarchical budgets, durable suspension, compaction, and a scoped security model — the parts that are genuinely hard to build correctly and that behave identically in six languages.

It is not the product around the engine. You bring:

Not shipped What you’d add Why it isn’t in the library
A TUI Your own renderer over streaming events Terminal UX is opinionated and app-specific.
A diff viewer / patch review UI Render edit/apply_patch args yourself before approving Same reason — it is presentation.
LSP / type-aware navigation An MCP server exposing your language server, wired via mcp.json This is exactly what MCP is for; toolnexus consumes it.
Git safety (worktrees, auto-branch, rollback) Your own tools, or a policy in AGENTS.md + the approval gate Repo policy varies too much to standardize.
Sandboxing Run the process in a container/jail; drop bash for untrusted input bash is a real shell. Process isolation is the host’s job, not a flag.
Cost in currency Convert totalTokens with your own price table Vendor pricing is vendor data — budgets are deliberately token-denominated.

Honest limits worth knowing before you commit:

  • Token counting is an estimate. The compactor’s default countTokens is ceil(chars/4) per message — an estimator, not a tokenizer. Pass your own for exactness, and leave headroom.
  • Budgets are eventually consistent between turn boundaries. Enforcement happens before each turn and each spawn, so a single in-flight turn can overshoot its ceiling before being stopped.
  • The agent runtime does not expose client-loop hooks. No beforeTool/afterTool inside agent(). Gate in the tool (above), or run that stage on the client loop.
  • Cancellation latency differs by port. JS/Go/C#/Elixir abort mid-request; Python and Java cancel cooperatively between attempts. The observable outcome is identical everywhere — an interrupted result and a restored inbox — only the latency differs.