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.

Audience · engineers wiring a coding harness into a pipeline Needs · a real workspace and a harness — nothing runs in this page Source · 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.

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.