# Coding agent (/docs/coding-agent)



A patient gardener — reads the bed, makes one change, checkpoints,
moves on. A coding agent is the use case where
[checkpointing](/docs/checkpointing) and [replay](/docs/eval-and-replay)
earn their keep. The agent makes destructive changes — file writes,
shell commands, package installs. When something goes wrong, you
don't want to read logs; you want to fork the session from the last
checkpoint and replay with one variable changed.

This page walks the sandboxed tool surface, the per-edit
checkpoint pattern, and the replay-as-debugger workflow.

**Related shapes.**
[Regulated-domain agent](/docs/regulated-domain-agent) if
generated code touches PHI/PII paths or ships under HIPAA / SOC 2.
[Research agent](/docs/research-agent) for the subagent fanout
pattern many coding agents reach for.
[Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent) if one
runtime serves many developer workspaces.

## `@pleach/coding-agent/runtime` contract [#pleachcoding-agentruntime-contract]

`@pleach/coding-agent` ships a typed `CodingAgentRuntime` contract
on the `/runtime` subpath today. All three async methods have
real bodies: `start()` composes the underlying
`SessionRuntime` from the configured strategy, `stop()`
drains it via `destroy()` and dispatches sandbox-provider
teardown, and `executeStep()` drives one turn through
`SessionRuntime.executeMessage` and projects the trailing turn
state into a `CompositeToolResult`. The body throws
`PACK_270_D3_EXECUTE_STEP_NOT_STARTED_MESSAGE` if called before
`start()` — no auto-start, by design. The locked decisions below
shape how the runtime interlocks with `@pleach/core` and
`@pleach/sandbox` from here on.

```typescript
import {
  createCodingAgentRuntime,
  SANDBOX_SHAPE_SENTINEL,
  type CodingAgentRuntime,
  type CodingAgentRuntimeConfig,
} from "@pleach/coding-agent/runtime";
```

| Method                 | Returns                                   | Status                                                                                                                                                                                                                                                                                                              |
| ---------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `start(input)`         | `Promise<CodingAgentStartOutput>`         | Live. Composes the underlying `SessionRuntime` via the configured strategy, captures the closure handle the `stop()` body drains, and returns a `{ sessionId, sandboxReady, startedAtEpochMs }` envelope.                                                                                                           |
| `stop(input)`          | `Promise<void>`                           | Live. Drains the in-flight `SessionRuntime` via `destroy()` and duck-type-dispatches an optional `dispose` / `destroy` / `shutdown` on the sandbox provider. Idempotent — `stop()` before `start()` or twice in a row resolves cleanly.                                                                             |
| `executeStep(input)`   | `Promise<CompositeToolResult>`            | Live. Drives one turn through `SessionRuntime.executeMessage(sessionId, content)` with warm-pool semantics, drains to terminal, projects the trailing turn state into `CompositeToolResult`. Throws `PACK_270_D3_EXECUTE_STEP_NOT_STARTED_MESSAGE` if called before `start()`.                                      |
| `getContextSnapshot()` | `{ entries: []; totalBytes: 0 }` (frozen) | Returns synchronously — the first-slice convention.                                                                                                                                                                                                                                                                 |
| `readToolEventLog()`   | `readonly CodingAgentToolEvent[]`         | Live. Returns the raw `tool.started` / `tool.completed` / `tool.failed` events the most-recent `executeStep` emitted, verbatim and in emission order — distinct from the `CompositeToolResult.tools[]` projection. `[]` before the first step. For observability/test harnesses asserting what the runtime emitted. |

Pin the version exactly — `0.1.0` is the first cut.

```bash
npm install @pleach/coding-agent
```

Pin `0.1.0` exactly in `dependencies` — `^0.1.0` will not pick
up later 0.x releases.

### Locked decisions [#locked-decisions]

Six contract decisions shape how `@pleach/coding-agent` interlocks
with `@pleach/core` and `@pleach/sandbox` from here forward.

* `@pleach/sandbox` is a peer dep at `^0.1.0`, not a hard dep.
* No plan-generation re-export from the coding-agent surface.
* The `_sandbox_shape` sentinel and `CompositeToolResult` live in `@pleach/coding-agent`, not `@pleach/core`.
* No `maxSynthesizePerTurn` field on the runtime contract — config-only, consumed by `TurnSynthesizeCounter` at boot.
* Guest mode deferred — coding-agent users authenticated at v1.x.
* Direction lock — `@pleach/coding-agent` consumes `@pleach/core` + `@pleach/sandbox`; `@pleach/core` must not import any coding-agent surface.

The direction lock is the load-bearing one. `@pleach/core` stays
sandbox-agnostic and coding-agent-agnostic by construction, so a
host that doesn't want either dependency tree doesn't pay the
weight.

### When to use the package vs. the inline pattern below [#when-to-use-the-package-vs-the-inline-pattern-below]

The rest of this page documents the **inline pattern** — wire
`SessionRuntime` directly, register sandbox tools, manage
checkpoints yourself. That's the long-form path: useful when you
want full control over tool registration, sandbox provisioning,
and checkpoint cadence. `createCodingAgentRuntime()` composes the
same surface — `start()` + `stop()` + `executeStep()` — without
the per-host wiring; pick that when the standard composition fits.

## What you're building [#what-youre-building]

An agent that, given a task, can read repository files, write
new files, and execute shell commands in an isolated workspace.
The workspace is per-session; the [audit trail](/docs/audit-ledger) is per-turn; the
checkpoint is per significant edit.

The agent doesn't run on your laptop. It runs in a sandbox you
control — a Docker container, a Firecracker microVM, an E2B or
Modal sandbox, your choice. Pleach's role is the runtime + ledger +
checkpointer around it.

## Sandbox tool surface [#sandbox-tool-surface]

Four [tools](/docs/tools), each Zod-validated, each scoped to the workspace
root. The runtime serializes the input schema into the prompt,
so the model can't pass a path it wasn't told about.

{/* TODO: ctx.runtime.checkpoint not on ToolContext today; sandbox
    surface lives on `@pleach/sandbox` provider, not ctx. The
    `ToolContext` shipped in `@pleach/core@0.1.0` carries only
    `toolCallId` + `signal?` (per packages/core/dist/tools/defineTool.d.ts).
    The host wires a sandbox provider externally and closes over it
    when constructing the tools; the per-edit checkpoint pattern
    routes through `runtime.checkpoint()` at the SessionRuntime layer,
    not from inside the handler. Code block below shows the canonical
    `defineTool` field vocabulary (inputSchema / outputSchema /
    execute); host-supplied sandbox + checkpoint hooks are stubbed
    until the surface is documented. */}

```typescript
// lib/tools/codingTools.ts
import { defineTool } from "@pleach/core";
import { z } from "zod";

// Host-supplied sandbox handle. `@pleach/sandbox` is a sibling SKU
// (peer-dep at ^0.1.0) — the runtime stays sandbox-agnostic so the
// host wires the provider and closes over it here.
declare const sandbox: {
  readFile(path: string): Promise<{ contents: string; bytes: number }>;
  writeFile(path: string, contents: string): Promise<{ bytesWritten: number }>;
  exec(cmd: string, opts: { timeoutMs: number }): Promise<{ stdout: string; stderr: string; exitCode: number }>;
  list(path: string): Promise<Array<{ name: string; type: "file" | "dir" | "symlink" }>>;
};

const PathInWorkspace = z.string().regex(/^[^/].*$/, "relative paths only");

export const readFile = defineTool({
  name: "read_file",
  description: "Read a UTF-8 file from the workspace. Returns the file contents.",
  inputSchema: z.object({ path: PathInWorkspace }),
  outputSchema: z.object({ contents: z.string(), bytes: z.number().int() }),
  async execute({ path }, _ctx) {
    return await sandbox.readFile(path);
  },
});

export const writeFile = defineTool({
  name: "write_file",
  description: "Write a UTF-8 file to the workspace. Overwrites if present.",
  inputSchema: z.object({
    path:     PathInWorkspace,
    contents: z.string(),
  }),
  outputSchema: z.object({ bytesWritten: z.number().int() }),
  async execute({ path, contents }, _ctx) {
    // The per-edit checkpoint fires from the SessionRuntime layer (see
    // "The checkpoint pattern" below) — `ToolContext` does not expose
    // `ctx.runtime.checkpoint` today.
    return await sandbox.writeFile(path, contents);
  },
});

export const runShell = defineTool({
  name: "run_shell",
  description: "Run a shell command in the workspace. Returns stdout, stderr, and exit code.",
  inputSchema: z.object({
    command:   z.string().min(1),
    timeoutMs: z.number().int().max(60_000).default(15_000),
  }),
  outputSchema: z.object({
    stdout:   z.string(),
    stderr:   z.string(),
    exitCode: z.number().int(),
  }),
  async execute({ command, timeoutMs }, _ctx) {
    return await sandbox.exec(command, { timeoutMs });
  },
});

export const listDir = defineTool({
  name: "list_dir",
  description: "List entries in a workspace directory. Returns names and types.",
  inputSchema: z.object({ path: PathInWorkspace }),
  outputSchema: z.array(z.object({
    name: z.string(),
    type: z.enum(["file", "dir", "symlink"]),
  })),
  async execute({ path }, _ctx) {
    return await sandbox.list(path);
  },
});
```

`write_file` and `run_shell` are the destructive surfaces — the
per-edit checkpoint that brackets them is wired at the SessionRuntime
layer (next section), not from inside the tool handler. If the
action breaks the workspace, the checkpoint is the rollback target.

## Runtime construction [#runtime-construction]

The sandbox is the per-invocation context. Each session gets a
fresh workspace; the [storage](/docs/storage) adapter persists the session
history, and the checkpointer persists workspace snapshots.

```typescript
// lib/runtime.ts
import { SessionRuntime, AiSdkProvider } from "@pleach/core";
import { SupabaseAdapter } from "@pleach/core/sessions";
import { SupabaseSaver }   from "@pleach/core/checkpointing";
import { definePleachPlugin } from "@pleach/core/plugins";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";

const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! });

export async function buildCodingRuntime(sessionId: string) {
  const sandbox = await openSandbox({ sessionId });

  // Tools register through a `contributeTools` plugin — there is no
  // `tools` field on the SessionRuntime config. The plugin closes over
  // the per-session `sandbox` the handlers execute against.
  const sandboxToolsPlugin = definePleachPlugin("coding-agent-sandbox-tools", {
    _raw: {
      version: "1.0.0",
      contributeTools: () => [readFile, writeFile, runShell, listDir],
    },
  });

  return new SessionRuntime({
    provider:     new AiSdkProvider({
      model:    openrouter("anthropic/claude-sonnet-4-5"),
      maxSteps: 5,
    }),
    storage:      new SupabaseAdapter({ client: supabase }),
    checkpointer: new SupabaseSaver({ client: supabase }),
    plugins:      [sandboxToolsPlugin],
  });
}
```

`@pleach/coding-agent/runtime` wraps this wiring in one call:
`createCodingAgentRuntime({ tools, providerStream, model })` registers
the tools as a `contributeTools` plugin and installs the provider
stream's tool-executor relay for you. Reach for the raw construction
above only when you want to own the runtime config directly.

Shell-command guardrails register as a **safety policy**, not as a
`SessionRuntime` field. A plugin contributes them through
`contributeSafetyPolicies()`, each built with
`defineSafetyPolicy({ name, appliesTo: "pre-dispatch", check })` —
capability-subtracting, registered by the plugin, enabled by the
operator. Policies are advisory by default (v1.1 routes enforcement
through `log_only`); seam-level blocking is forthcoming. See
[Safety](/docs/safety).

## The checkpoint pattern [#the-checkpoint-pattern]

{/* TODO: `ctx.runtime.checkpoint(label)` is not on ToolContext in
    @pleach/core@0.1.0. The ToolContext surface today is
    `{ toolCallId, signal? }`. Per-edit checkpoints land at the
    SessionRuntime layer — the host code surrounding `executeMessage`
    invokes the checkpointer between tool calls. Section preserved
    to describe the intended pattern; concrete signature pending. */}

The intended pattern is: each significant edit gets a snapshot row
keyed by `sessionId`, `turnId`, and a label. The checkpointer adapter
decides what gets snapshotted — for a coding agent, that's the
workspace filesystem + the session message history.

The concrete API for surfacing this from inside the tool path is
still being shaped; today, hosts wire checkpoints from the layer
that drives `runtime.executeMessage`, not from inside the handler.

A typical agent run on a single task looks like:

```text
turn-start
  tool-call    list_dir
  tool-call    read_file        src/lib/util.ts
  checkpoint   pre-write:src/lib/util.ts
  tool-call    write_file       src/lib/util.ts
  checkpoint   pre-shell:pnpm test
  tool-call    run_shell        pnpm test    exit=1
  tool-call    read_file        test/util.test.ts
  checkpoint   pre-write:test/util.test.ts
  tool-call    write_file       test/util.test.ts
  checkpoint   pre-shell:pnpm test
  tool-call    run_shell        pnpm test    exit=0
turn-complete
```

Eight checkpoints in one turn. Each one is a viable fork point.

## Replay as debugger [#replay-as-debugger]

A user reports: "the agent broke my build at commit X." You don't
read logs; you replay the turn and look at the tool sequence.

```typescript
import { createReplayRuntime } from "@pleach/replay";

const replayRuntime = createReplayRuntime({
  tenantId: "acme-corp",
  sessionRuntime: runtime,
});
const replay = await replayRuntime.replayTurn({
  chatId: sessionId,
  tenantId: "acme-corp",
  messageId: brokenTurnId,
});

const { toolCalls } = replay.state as { toolCalls: Array<{ name: string; input: unknown; output: unknown }> };
for (const call of toolCalls) {
  console.log(call.name, call.input, call.output);
}
```

`replay` walks the turn [deterministically](/docs/determinism): same provider seed,
same tool returns (replayed from the ledger), same final text.
The only thing that varies is your code under the tool handlers.
Change the safety policy, change the prompt, change the model —
and rerun. The diff is the answer.

See [Eval and replay](/docs/eval-and-replay) for the recording
modes.

## Fork from a checkpoint [#fork-from-a-checkpoint]

When the user wants to *recover* the workspace, not just debug
it: fork from the last good checkpoint and continue the session
with a corrective message.

Forking runs through the `TimeTravelAPI` facet —
`runtime.timeTravel.api.fork(sourceSessionId, checkpointId, newSessionId)`.
You supply the new session id; the call clones the checkpoint
state onto it.

```typescript
const newSessionId = `${brokenSessionId}-recover`;

await runtime.timeTravel.api.fork(
  brokenSessionId,
  lastGoodCheckpointId,
  newSessionId,
);

for await (const _event of runtime.executeMessage(
  newSessionId,
  "The previous edit broke the test suite. Revert that edit and try a different approach.",
)) {
  // drain to terminal
}
```

The new session inherits the workspace snapshot and the message
history up to the checkpoint. It carries `forkedFrom` lineage
metadata (source session id + checkpoint id) so the relationship
is queryable.

## Project layout [#project-layout]

Three adds on top of the [baseline](/docs/project-layout#a-layout-that-works):
a sandbox-scoped tool surface, a *durable*
[`SupabaseSaver`](/docs/checkpointing) for the per-edit
checkpoints, and a `debug/` entry point so
[replay as debugger](#replay-as-debugger) is one command instead
of a one-off script.

```
my-app/
  src/
    pleach/
      runtime.ts                # SessionRuntime + SupabaseAdapter + SupabaseSaver (durable, mandatory)
      tools/
        sandbox/                # everything here runs inside the sandbox
          read-file.ts          # defineTool
          write-file.ts         # defineTool — destructive; triggers checkpoint
          run-command.ts        # defineTool — destructive; triggers checkpoint
      checkpoints/
        policy.ts               # "snapshot before each destructive tool" rule
      debug/
        fork-and-replay.ts      # timeTravel.api.fork + replayTurn
    app/
      api/agents/[id]/route.ts
```

What changes from the baseline:

* **`tools/sandbox/` is the security boundary.** Everything under
  it executes against the sandbox provider — not the host
  filesystem, not host shell. Putting non-sandboxed tools next to
  these would erase the boundary; keep them in `tools/host/` if
  you have them.
* **Durable checkpointing is mandatory, not optional.** The
  in-memory default loses every per-edit snapshot at restart —
  which deletes the entire
  [fork-from-checkpoint](#fork-from-a-checkpoint) workflow. Use
  `SupabaseSaver` (or any [Checkpointing](/docs/checkpointing)
  adapter that persists).
* **`checkpoints/policy.ts` is a real file.** The "snapshot before
  each destructive tool" rule lives in code, not in a docstring.
  When the destructive-tool set grows, the policy file is the one
  place it gets added.
* **`debug/fork-and-replay.ts` is product code.** Replay-as-
  debugger isn't a script you write when something breaks; it's a
  function your support tooling already imports. When the
  [sandbox-shape decisions](#locked-decisions) shift in a later
  version, this file is what gets updated.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Checkpointing" href="/docs/checkpointing" description="Snapshot storage shape and the rollback API." />

  <Card title="Eval and replay" href="/docs/eval-and-replay" description="runtimeMode, recording, and the diff engine the replay-as-debugger flow leans on." />

  <Card title="Safety" href="/docs/safety" description="The capability-subtracting policy contract — denylist, rate limit, redact." />

  <Card title="Regulated-domain agent" href="/docs/regulated-domain-agent" description="Pre-dispatch redaction and the chain-of-custody query if your coding agent touches regulated source." />
</Cards>
