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/, src/executors/

Read this first

Every surface on this page is Experimental

Experimental means no deprecation cycle. The coding-agent and executor surfaces ship only behind their own subpaths — @animahealth/adk/agents/coding, @animahealth/adk/agents/coding/claude-code, and @animahealth/adk/executors — and any release may change or remove them. A build gate keeps them out of the main entry precisely so that nothing here 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 checked against the package source by hand rather than by pressing Run — treat it as a transcription of src/agents/coding/ and src/executors/, and read those 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 deliberately 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.

// 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.

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 } from '@animahealth/adk/agents/coding'
import { createWorkspaceProvisioner } from '@animahealth/adk/executors'

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 honestly. 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 deliberately the smallest interface that can: one method in, a disposable handle out.

// src/executors/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 — the failure mode that silently lets two coding agents edit the same tree is simply not reachable.

import {
  createWorkspaceProvisioner,
  ISOLATION_STRATEGIES,        // ['session', 'worktree', 'sandbox']
  isIsolationStrategy,
  UnknownIsolationStrategyError,
  WorkspaceProvisionError,
} from '@animahealth/adk/executors'

// 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 invariant is structural rather than advisory. 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,
})

The other seam

Executors: where a turn's environment comes from

@animahealth/adk/executors holds two different seams, and telling them apart is most of understanding the module. The WorkspaceProvisioner above is per-run: it exists for the length of one coding node. An Executor is per-turn and belongs to the ADK's long-running process machinery — it prepares the environment an agent's turn executes in, and hands back the events that turn produced.

// src/gateway/gateway-types.ts
interface Executor {
  readonly name: string
  // request: { process, session, agent, messages, signal }
  // result:  { status, nextWakeAt?, error?, events, executorConfig? }
  execute(
    request: ExecutionRequest,
    onEvent: (event: StreamEvent) => void,
  ): Promise<ExecutionResult>

  cleanup?(processId: string): Promise<void>
  getPreviewUrl?(processId: string): Promise<string | null>
  listWorkspaceFiles?(processId: string): Promise<string[] | null>
}

Two implementations ship: createDockerExecutor runs turns in local containers against a bind-mounted repository, and createModalExecutor runs them in remote sandboxes cloned from a git URL. Both take a session store, because a turn's events have to land somewhere durable, and both need real infrastructure — a Docker daemon, or a Modal account and its tokens. Neither is something app.run() reaches on its own — and the Executor type above is not itself exported, so today you consume the two factories rather than write a third implementation.

import { createDockerExecutor, createModalExecutor } from '@animahealth/adk/executors'

const local = createDockerExecutor({
  sessionStore,
  repoPath: '/path/to/repo',      // bind-mounted into each container
  hooks: {
    afterCreate: async (ctx) => { await ctx.exec('npm install') },
    afterRun: async (ctx) => { ctx.log(`turn done in ${ctx.workspace.path}`) },
  },
})

const remote = createModalExecutor({
  sessionStore,
  defaultWorkspace: { repoUrl: 'https://github.com/org/repo.git', baseBranch: 'main' },
})

Those hooks are the extension point worth knowing: afterCreate, beforeRun, afterRun, and beforeDestroy, each either a shell string or a function receiving { workspace, process, session, log, exec }. Dependency installation, artifact collection, and branch pushes hang off those four points rather than off the executor's internals.

Workspace tools: an ordinary agent, scoped to a directory

The most immediately useful export in the module needs no infrastructure at all. workspaceTools returns file tools bound to a root — the ingredients for a coding agent you build yourself out of ADK primitives, rather than one you bring from outside.

import { workspaceTools, DEFAULT_BLOCKED_COMMANDS } from '@animahealth/adk/executors'

// Returns: [read, write, edit, grep, glob, shell]
const tools = workspaceTools({
  root: '/workspace/my-project',
  sandboxed: false,       // default. Mutating tools carry requiresApproval: true.
  allowShell: true,       // default. Set false to drop the shell tool entirely.
  maxFileSize: 10 * 1024 * 1024,
  shellTimeout: 30_000,
})

const editor = app.agent({ name: 'editor', model, tools })

Two behaviors carry the safety of that list. Every path is resolved against the root and a path that escapes it throws — no ../.. reaches outside the workspace. And while sandboxed is false, write, edit, and shell are marked requiresApproval, so the runner pauses and asks before each one; see Stopping to ask for what that pause looks like. Set sandboxed: true only when a container is the thing containing the damage.

The shell tool's blocklist is a seatbelt, not a sandbox. DEFAULT_BLOCKED_COMMANDS is a list of regexes covering the obvious hazards — sudo, rm -rf /, pipe-to-shell downloads, container escapes — and it is exported so you can extend or replace it. It stops a careless command, not a determined one. Real isolation comes from the environment the tools run in.

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

Edges to know about

Experimental is a promise about churn, but these are specific and current. Each is a place the surface reads more complete than it is.

Edge What actually happens
Mid-run steering CodingHandle.send accepts message and tool_response, but the Claude Code agent implements only abort — the others log a warning and do nothing. Treat send as an abort channel until that changes.
Codex The seam is provider-neutral and the interface is the whole contract, but no Codex adapter ships. Bringing one is the build function in step 2, not a fork.
The harness dependency @anthropic-ai/claude-agent-sdk is not declared in the package's dependencies at all, and nothing imports it statically — createClaudeCodeAgent loads it on the first run, so importing the subpath or building a factory costs nothing, and a run without it fails with one clear error naming the package. That lazy import also means npm install will not fetch it for you: install it explicitly alongside the ADK to run a Claude Code node.
Provisioner defaults With no ProvisionerBackends supplied, provision returns an in-process placeholder: a path like /repo/.adk-worktrees/<stamp> that nothing has created, and a dispose that does nothing. Enough to exercise the lifecycle in a test; not enough to isolate a real run. Supply real backends before trusting the word "isolation".
The default delta probe Synthesized from the result's modifiedFiles, not from the filesystem: the diff becomes modified <file> lines and the command result becomes status: completed, so the default score reduces to "did the coding agent claim to touch a file?" — the question the delta exists to stop asking. 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.
Errors from the tool form run() resolves an error-status CodingResult when a run fails, but execute() — the tool form — drains the stream first, so a failing run rejects there instead. Handle both shapes if a coding agent sits in an orchestrator's tools: array.

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.