Agent Development Kit · Experimental
Coding agents, whichever harness you bring
A coding agent is an ordinary ADK agent whose work is filesystem state. This chapter is the
contract around one: the provider-neutral CodingAgent interface, the
CodingAgentFactory seam that swaps the harness underneath it, the provision →
dispose lifecycle that keeps a run from leaking a workspace, and the rule that decides whether
the run passed — the diff, never the summary.
src/agents/coding/
Read this first
Every surface on this page is Experimental
Experimental means no deprecation cycle. The coding-agent surfaces ship
only behind their own subpaths — @animahealth/adk/agents/coding and
@animahealth/adk/agents/coding/claude-code — and any release may change or
remove them. Nothing here is re-exported from the main entry, so none of it can reach you
wearing a Core badge. Pin your version. The package README’s Stability section says
what the two tiers promise.
The other reason this chapter reads instead of runs: every block below needs a real
filesystem, a real repository, and a coding harness process. The cells elsewhere on this site
execute the ADK inside your browser, and none of that exists there. So the code here is read
rather than run — it transcribes src/agents/coding/, which is the thing to open
when something surprises you.
Step 1
A coding agent is a runnable and a tool
Claude Code, Codex, and their kin are already agents: they plan, call tools, edit files, and
stop. The ADK does not re-implement that loop — it wraps one in an interface, so the harness
becomes a thing you can hand to an orchestrator, drop into a sequence, or run on its own.
CodingAgent is that interface, and it is two things at once.
// src/agents/coding/types.ts
interface CodingAgent<S extends StateSchema = StateSchema>
extends FunctionTool<CodingToolInput, CodingResult, StreamEvent, S> {
name: string
description: string
schema: z.ZodType<CodingToolInput>
/** Standalone: returns a handle you can stream AND await. */
run(task: string | CodingTask): CodingHandle
/** Tool form: what the ADK calls when the agent sits in a `tools:` array. */
execute(
ctx: ToolExecutionContext<CodingToolInput, StreamEvent, unknown, S>,
): Promise<CodingResult>
/** Only needed to rename or re-describe the tool form. */
asTool(options?: CodingToolOptions): FunctionTool<CodingToolInput, CodingResult, StreamEvent, S>
}
Because execute is there, a coding agent needs no adapter to be a tool: put it in
tools: [coder] and an orchestrating model can delegate to it. Its input schema is
two fields — task and an optional sessionId — so the model's job is
to write a brief, not to drive an editor. It differs from run on failure:
execute drains the stream, so a failing run rejects there rather than resolving
an error-status result.
// src/agents/coding/types.ts
interface CodingTask {
task: string
sessionId?: string // resume a previous harness session
signal?: AbortSignal
}
/** Streams events AND resolves the result — off the same object. */
interface CodingHandle extends AsyncIterable<StreamEvent>, PromiseLike<CodingResult> {
send(input: CodingInput): void
abort(): void
}
type CodingInput =
| { type: 'message'; text: string }
| { type: 'tool_response'; callId: string; approved: boolean }
| { type: 'abort' }
The handle is the ergonomic part. Iterate it to watch the harness work, then await the same
object for the verdict — awaiting on its own consumes the stream for you, so you never have to
drain it by hand. With the shipped Claude Code agent, send is an abort channel:
it acts on abort and logs a warning for the other two.
import { createClaudeCodeAgent } from '@animahealth/adk/agents/coding'
const coder = createClaudeCodeAgent({
workspace: '/path/to/repo',
config: { permissionMode: 'acceptEdits', maxTurns: 50 },
})
const handle = coder.run('Fix the failing test in auth.test.ts, then run the unit tests.')
for await (const event of handle) {
console.log(event.type) // assistant, thought, tool_call, tool_result, system, deltas
}
const result = await handle // the same handle, now a CodingResult
The events are ADK StreamEvents, not harness messages: the Claude Code adapter
maps assistant text, thinking blocks, tool calls, and tool results onto the same vocabulary
the rest of this site uses, so a coding agent's stream renders in whatever already renders an
agent's stream. What resolves at the end is aligned with a normal run result, with two
coding-specific additions.
// src/agents/coding/types.ts
interface CodingResult {
status: 'completed' | 'error' | 'aborted' | 'max_turns' | 'max_duration'
sessionId: string // always present — the resume handle
output: Output<CodingOutput> // output.text = the agent's summary
usage?: UsageSummary // ADK's usage shape: tokens, modelCalls, cost
error?: CodingError
durationMs?: number
}
interface CodingOutput {
modifiedFiles: string[] // provenance, gathered from the run's write/edit tool calls
metadata?: Record<string, unknown>
}
interface CodingError {
message: string
code: 'rate_limited' | 'context_exhausted' | 'sdk_error' | 'aborted' | 'timeout' | 'unknown'
retryAfter?: number // seconds, when code is 'rate_limited'
}
sessionId is the durable part. Pass it back as
coder.run({ task, sessionId }) and the harness resumes its own conversation — the
ADK stores nothing itself. modifiedFiles is provenance, not proof: it is
collected by watching the run's write and edit tool calls, so it
records what the coding agent said it was doing. Proof comes from the workspace, in
step 5.
Step 2
The factory seam: one node body, any harness
An interface alone does not make a harness swappable. The thing that forks a codebase is the
constructor: every call site that says createClaudeCodeAgent(…) is a
place a second harness has to be threaded through. So construction goes behind one seam.
CodingAgentFactory.create takes a workspace and hands back a
CodingNode — a coding agent already bound to that directory.
// src/agents/coding/factory.ts
interface CodingAgentFactory {
create(opts: { workspace: string; signal?: AbortSignal }): CodingNode
}
interface CodingNode {
readonly workspace: string
run(task: string | CodingTask): Promise<CodingNodeOutcome>
}
interface CodingNodeOutcome {
workspace: string
task: string
delta: EnvironmentDelta // { diff, commandResult } — the scoring surface
summary?: string // the agent's own words. Display only; never scored.
result: CodingResult // the raw result, kept for provenance
}
Note what create does not do: it does not provision. The workspace path
is an input, materialized by someone else before the call. That ordering is the seam's whole
contract — provision, then construct, then run — and it is what lets the same node body drive
a local git worktree and a remote sandbox without knowing which it got.
Two factories ship. The first wraps the Claude Code agent:
import { createClaudeCodeFactory } from '@animahealth/adk/agents/coding'
const factory = createClaudeCodeFactory({
claudeCode: { config: { permissionMode: 'acceptEdits' } },
delta: async (result, ctx) => ({
diff: await git(ctx.workspace, 'diff'),
commandResult: await sh(ctx.workspace, 'npm test'),
}),
})
const node = factory.create({ workspace: '/repo/.worktrees/run-17', signal })
const outcome = await node.run('Implement the failing requirement; run the unit tests.')
The second takes any CodingAgent you can build. This is the
bring-your-own-harness door: implement the interface over Codex, over an in-house coding
agent, over a shell script that shells out to something entirely different, and pass a
build function. Nothing downstream changes — not the node, not the lifecycle, not
the scoring.
import { createCodingAgentFactory, codingToolInputSchema } from '@animahealth/adk/agents/coding'
import type { CodingAgent, CodingHandle, CodingResult } from '@animahealth/adk/agents/coding'
// Your harness, wearing the ADK's interface. `startHarness` returns a CodingHandle:
// an AsyncIterable of StreamEvents that is also a PromiseLike of a CodingResult.
function myCoder({ workspace }: { workspace: string }): CodingAgent {
const agent: CodingAgent = {
name: 'my-coder',
description: 'Runs the in-house coding harness in a provisioned workspace.',
schema: codingToolInputSchema,
run: (task): CodingHandle => startHarness(workspace, task),
execute: (ctx): Promise<CodingResult> => Promise.resolve(startHarness(workspace, ctx.args)),
asTool: (options) => ({
name: options?.name ?? agent.name,
description: options?.description ?? agent.description,
schema: agent.schema,
execute: agent.execute,
}),
}
return agent
}
const factory = createCodingAgentFactory({
build: myCoder,
delta: async (result, ctx) => ({
diff: await git(ctx.workspace, 'diff'),
commandResult: await sh(ctx.workspace, 'npm test'),
}),
})
build is required and create validates.
createCodingAgentFactory({}) throws on the missing build — use
createClaudeCodeFactory() if the shipped coding agent is what you wanted. And
create({ workspace: '' }) throws rather than quietly running the harness in
whatever directory the process happens to be in. Both are refusals by design: a coding agent
pointed at the wrong tree is the expensive kind of mistake.
One subtlety in how the signal flows. create({ workspace, signal }) stores the
signal and threads it into each node.run(task) — unless the task carries
its own, which wins. Construction is never cancelled; the in-flight run is.
Step 3
Provision, construct, run, dispose — then score
Those four steps in that order, with disposal guaranteed, are easy to write and easy to get
subtly wrong: dispose in the happy path only and an aborted run leaks a worktree; construct
before provisioning and a bad path costs you a harness session before it fails.
runCodingNode is that sequence, written once.
{ path, dispose, isolation }. A failure here — or a backend that yields no
path — throws before any coding agent exists, so a bad path costs zero harness
calls and zero tokens.
factory.create({ workspace, signal }). The node body never
names a harness constructor, which is what makes the swap in step 2 real.
node.run — while the workspace still exists.
finally, exactly once, whether the coder returned, threw, or was
aborted. Guarded by a flag, so a retry path cannot double-dispose.
./eval metric receives { diff, commandResult } — and only
that. See step 5.
import { runCodingNode, createWorkspaceProvisioner } from '@animahealth/adk/agents/coding'
const { outcome, score } = await runCodingNode({
factory, // CodingAgentFactory
provisioner: createWorkspaceProvisioner(), // WorkspaceProvisioner
base: '/repo', // repo root or parent directory
isolation: 'worktree', // 'session' | 'worktree' | 'sandbox'
task: 'Implement the failing requirement; run the unit tests.',
signal: ctx.signal, // optional
metric: myMetric, // optional; defaults to codingDeltaMetric()
})
score.passed // reflects outcome.delta — never outcome.summary
outcome.workspace // the path that existed during the run; it is gone by now
outcome.result // the raw CodingResult: status, sessionId, usage, modifiedFiles
Read the ordering once more, because it has a consequence people trip over: the workspace is
already disposed by the time the metric runs. A metric that wants to look at files cannot —
that is what the delta probe is for, and the probe fires inside node.run, before
the finally. Gather your evidence in the probe; score it afterwards.
Failures propagate. A coder that throws throws out of runCodingNode — after
disposal. A provisioning failure surfaces as WorkspaceProvisionError or
UnknownIsolationStrategyError, unchanged, because those errors already name the
offending strategy and base path. An abort mid-run cancels the coder, skips the remaining
work, and still disposes.
Step 4
Workspace isolation is a seam, not a policy
Concurrent coding agents sharing a working tree is not a race you want to debug. The
WorkspaceProvisioner gives each run its own directory, and it is the smallest
interface that can: one method in, a disposable handle out.
// src/agents/coding/workspace-provisioner.ts
interface WorkspaceProvisioner {
provision(base: string, isolation: string): Promise<ProvisionedWorkspace>
}
interface ProvisionedWorkspace {
path: string // the coder's working directory
dispose: () => Promise<void> | void // called exactly once, after the run
isolation: 'session' | 'worktree' | 'sandbox'
}
| Strategy | What a host is expected to materialize |
|---|---|
'session' |
An ephemeral directory under the base path, thrown away after the run. |
'worktree' |
A git worktree of the base repository — the usual choice when the base is a repo and the diff is the deliverable. |
'sandbox' |
An isolated sandbox from a provider. This is where a container or a remote microVM plugs in. |
The strategy string is validated before any backend runs, and an unknown one is a
hard error naming both the bad value and the valid set. There is no fallback to a shared
directory, so the failure mode that lets two coding agents edit the same tree is not
reachable. Supply a backend for every strategy you use: with none,
provision returns an in-process placeholder — a path nothing created, and a
dispose that does nothing. It exercises the lifecycle; it does not isolate a run.
import {
createWorkspaceProvisioner,
ISOLATION_STRATEGIES, // ['session', 'worktree', 'sandbox']
isIsolationStrategy,
UnknownIsolationStrategyError,
WorkspaceProvisionError,
} from '@animahealth/adk/agents/coding'
// Supply real materialization per strategy. Anything you omit falls back to the in-process
// default: a distinct, kind-tagged path under `base` with a no-op dispose.
const provisioner = createWorkspaceProvisioner({
worktree: async (base) => {
const path = `${base}/.worktrees/${crypto.randomUUID()}`
await sh(base, `git worktree add ${path}`)
return {
path,
isolation: 'worktree',
dispose: () => sh(base, `git worktree remove --force ${path}`),
}
},
})
await provisioner.provision('/repo', 'container')
// throws UnknownIsolationStrategyError:
// unknown isolation strategy: 'container'. Valid strategies: session, worktree, sandbox
Step 5
Scored on what the workspace shows
A coding agent's most confident sentence is "I've fixed the bug and all tests pass."
It is also the sentence least worth believing, because the coding agent that wrote it is the
one being judged. So the score never sees it. The scoring surface is the
EnvironmentDelta: the workspace diff, plus the output of whatever command
verifies it.
// src/agents/coding/factory.ts
interface EnvironmentDelta {
diff: string // the workspace diff after the run; '' when nothing changed
commandResult: string // the verification command / test output
}
type DeltaProbe = (
result: CodingResult,
ctx: { workspace: string; task: string; signal?: AbortSignal },
) => Promise<EnvironmentDelta> | EnvironmentDelta
The metric's input type is { diff, commandResult } — there is no summary field to
read, so no metric, custom ones included, can re-couple the score to the coding agent's
claims. A run that reports success over an empty diff scores as a failure, and no amount of
eloquence changes that.
import { codingDeltaMetric } from '@animahealth/adk/eval'
// The default metric. `passed` is true when the diff is non-empty AND the command result
// carries no failure token.
const metric = codingDeltaMetric()
// A custom one is the same two fields, plus a numeric score if you want one.
const strict = codingDeltaMetric({
name: 'green-tests-only',
passed: (delta) => delta.diff.trim() !== '' && delta.commandResult.includes('0 failed'),
score: (delta) => delta.diff.length,
})
Where the package stops
Running a coder somewhere other than this machine
The package ships one seam between a coding agent and the environment it works in: the
WorkspaceProvisioner above. It is per-run, it hands
back a path and a dispose, and runCodingNode drives it. There is no
second environment abstraction underneath it.
So running a coding agent off the local filesystem — in a container, in a remote sandbox, on a
machine that is not this one — is yours to build. The provisioner is where it plugs in: a
sandbox backend that materializes the environment, returns the path the coder
should work in, and tears it down in dispose. The package ships no container or
remote-sandbox backend, so nothing here spares you the provider's own SDK.
Aside
Testing the pipeline without a harness
Everything above is orchestration, and orchestration deserves tests that do not cost tokens or
need a subprocess. Two stand-ins implement CodingAgent with no harness behind
them: coding.mock replays a script, and coding.noop returns a
completed result immediately.
import { coding } from '@animahealth/adk/agents/coding'
const coder = coding.mock({
responses: [
{ type: 'assistant', text: 'Reading the failing test...' },
{ type: 'tool_call', name: 'write', args: { path: 'src/auth.ts', content: '...' } },
{ type: 'tool_result', name: 'write', result: 'File written' },
{ type: 'assistant', text: 'Fixed.' },
],
artifacts: [{ name: 'summary.md', content: '# Summary\nDone.' }],
delayMs: 0,
})
const result = await coder.run('Fix the failing test')
result.status // 'completed'
result.output.value?.modifiedFiles // ['src/auth.ts'] — derived from the scripted tool calls
The mock is faithful where it matters for a lifecycle test: it streams real
StreamEvents, honors abort(),
send({ type: 'abort' }) and an AbortSignal, and can be told to fail
on cue.
const flaky = coding.mock({
responses: [{ type: 'assistant', text: 'Starting...' }],
simulateError: { after: 1, message: 'harness died', code: 'sdk_error' },
})
// Iterating the handle throws; awaiting it yields status 'error' with that CodingError.
const stub = coding.noop({ name: 'code' })
// A FunctionTool that returns a completed CodingResult with no modified files.
Pair either with a stub WorkspaceProvisioner and you can assert the whole
lifecycle — provisioned before constructed, disposed exactly once on the throwing path, scored
on the delta — with no filesystem and no model. The rest of the test kit is
Testing agents without a model.
Before you build on it
Two things you supply yourself
Running a Claude Code node, and scoring it on anything real, both depend on something the package deliberately does not carry.
| What | Why, and what to do |
|---|---|
| The harness SDK |
@anthropic-ai/claude-agent-sdk is not a declared dependency, and nothing
imports it statically — createClaudeCodeAgent loads it on the first run.
So importing the subpath or building a factory costs nothing, npm install
will not fetch the SDK for you, and a run without it fails with one error naming the
package. Install it alongside the ADK to run a Claude Code node.
|
| The delta probe |
With no probe, the delta is synthesized from the result's modifiedFiles
rather than read from the filesystem: the diff becomes
modified <file> lines and the command result becomes
status: completed, so the default score answers only whether the coding
agent claimed to touch a file. The default passed predicate is blunt on
the other side too: any case-insensitive FAIL in the command result fails
the run, so a reporter line reading failures: 0 scores as a failure. Pass
a probe, and a predicate that matches your reporter's vocabulary.
|
Where this goes next: the orchestration that uses coding nodes as steps is Dynamic workflows, the metric vocabulary the delta score plugs into is Measuring agents, and the interface a coding agent satisfies to be a tool at all is Tools. If none of that is what you came for, Quickstart builds an ordinary agent in one page.