Coding agent
A sandboxed agent that reads, writes, and runs code — with a checkpoint per significant edit and a replay path for "why did it do that?"
A patient gardener — reads the bed, makes one change, checkpoints, moves on. A coding agent is the use case where checkpointing 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 if generated code touches PHI/PII paths or ships under HIPAA / SOC 2. Research agent for the subagent fanout pattern many coding agents reach for. Multi-tenant SaaS agent if one runtime serves many developer workspaces.
@pleach/coding-agent/runtime 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.
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.
npm install @pleach/coding-agentPin 0.1.0 exactly in dependencies — ^0.1.0 will not pick
up later 0.x releases.
Locked decisions
Six contract decisions shape how @pleach/coding-agent interlocks
with @pleach/core and @pleach/sandbox from here forward.
@pleach/sandboxis a peer dep at^0.1.0, not a hard dep.- No plan-generation re-export from the coding-agent surface.
- The
_sandbox_shapesentinel andCompositeToolResultlive in@pleach/coding-agent, not@pleach/core. - No
maxSynthesizePerTurnfield on the runtime contract — config-only, consumed byTurnSynthesizeCounterat boot. - Guest mode deferred — coding-agent users authenticated at v1.x.
- Direction lock —
@pleach/coding-agentconsumes@pleach/core+@pleach/sandbox;@pleach/coremust 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
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
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 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
Four 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.
// 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
The sandbox is the per-invocation context. Each session gets a fresh workspace; the storage adapter persists the session history, and the checkpointer persists workspace snapshots.
// 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.
The checkpoint pattern
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:
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-completeEight checkpoints in one turn. Each one is a viable fork point.
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.
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: 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 for the recording modes.
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.
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
Three adds on top of the baseline:
a sandbox-scoped tool surface, a durable
SupabaseSaver for the per-edit
checkpoints, and a debug/ entry point so
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.tsWhat 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 intools/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 workflow. Use
SupabaseSaver(or any Checkpointing adapter that persists). checkpoints/policy.tsis 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.tsis 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 shift in a later version, this file is what gets updated.
Where to go next
Checkpointing
Snapshot storage shape and the rollback API.
Eval and replay
runtimeMode, recording, and the diff engine the replay-as-debugger flow leans on.
Safety
The capability-subtracting policy contract — denylist, rate limit, redact.
Regulated-domain agent
Pre-dispatch redaction and the chain-of-custody query if your coding agent touches regulated source.