Skip to content
Docs

Tools, Files, and Documents in Durable Agent Harnesses

A durable harness keeps each session in a log: the conversation and every model call and tool call. This page covers what tools do after a crash, where an agent's files live, and how to keep typed state with a session. It applies to the durable-harness guides: Pi, Claude Code, Codex, and OpenCode.

After a crash, the harness reads a finished tool call back from the log and never runs it again. What happens to a call that a crash interrupted depends on the tool's replay rule:

RuleA call that a crash interrupts
'safe'It's safe to run again. Pi runs it again. Claude Code, Codex, and OpenCode tell the model it's safe to repeat.
'never'It never runs twice. The model is told it may or may not have happened, so it can check before it tries again.

Make tools replay-safe wherever you can. With durability: 'on', a replay-safe call starts without waiting for the log to record it. That's the default for durable-harness/pi. Claude Code, Codex, and OpenCode default to durability: 'strict', which waits for the log before every call, so pass durability: 'on' to their factory to run ahead too. fx starts idempotent calls without waiting. Before a 'never' call, every harness waits until the log has recorded it, so it never runs twice on its own.

An agent has only the tools you give it. Pass your own as tools. Each one is a HostTool with a replay rule. durable-harness/pi-durable takes Pi's defineTool() tools instead, as the Pi guide shows:

lib/agent.ts
import { pi } from 'durable-harness/pi';
import type { HostTool } from 'durable-harness';
import { getOrder, refundOrder } from './orders'; // your own code
 
const tools: HostTool[] = [
  {
    name: 'get_order',
    description: 'Looks up an order',
    replay: 'safe', // safe to run again after a crash
    inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
    execute: async ({ id }) => JSON.stringify(await getOrder(id)),
  },
  {
    name: 'refund_order',
    description: 'Refunds an order',
    replay: 'never', // never runs twice
    inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
    execute: async ({ id }) => JSON.stringify(await refundOrder(id)),
  },
];
 
export const agent = pi({ model: 'anthropic/claude-sonnet-5.5', tools });

Your tools run in your function, with its environment variables, and the model writes their arguments. Before you write one, read Your own tools on the Security page.

The session is durable, and you choose where the agent's commands and files live. They never run on your function's disk, in its processes, or with its environment, which other sessions on the same instance share. See Security. Give the agent a shell and files in one of three places:

A virtual workspaceA virtual workspace, not savedA Edgenivo Sandbox
Where commands runjust-bash in your function, on a virtual filesystemThe sameA virtual machine for the session
What commands can reachThe workspace only, not your function's files, processes, or environmentThe sameThe sandbox and its Drive
Files after a crashKept in the session's log. A call that a crash interrupts leaves nothing behindCleared at the end of every turn, and the model is toldKept on the Drive. A command that a crash interrupts may have partly run, and the model is told
Cost and speedYour function's CPU time, and the log events and storage each write addsYour function's CPU timeSandbox and Drive usage, and a sandbox start on first use
Files are deletedWith the session's logAt the end of every turnWhen you delete the session's Sandbox and Drive. Deleting idle ones is opt-in
Best forShort tasks and small workspacesScratch work within a turnReal projects and long-running work

Claude Code and Codex run in a Edgenivo Sandbox, where builtins: true turns on their own tools, on the session's Drive. See Why Claude Code and Codex run only in a Sandbox.

For long-running work and real projects, run the agent's tools in the session's own Edgenivo Sandbox, with /workspace on a Drive. The session stays durable in the log, and the files live on the Drive, which outlives every Sandbox and every function. The harness stays in your function, and the Sandbox starts at the first tool call. Install @vercel/sandbox, and give the harness's own tools sandbox():

lib/pi.ts
import { BACKGROUND_CONTEXT as ctx } from '@earendil-works/chord/context';
import { createRegistry, Harness, type Storage } from '@earendil-works/pi-durable';
import { CodingTools, gatewayModels, GATEWAY_PROVIDER, resumeWith, sandboxEnv } from 'durable-harness/pi-durable';
 
const models = gatewayModels();
const registry = createRegistry();
registry.install(CodingTools); // Pi's read, write, edit, and bash
 
export { ctx };
export const agent = { model: { provider: GATEWAY_PROVIDER, modelId: 'anthropic/claude-sonnet-5.5' } };
export const open = resumeWith((storage: Storage) =>
  Harness.open(storage, { models, registry, env: sandboxEnv() }, ctx),
);
lib/agent.ts
import { sandbox } from 'durable-harness';
import { createCodingTools, pi } from 'durable-harness/pi';
 
export const agent = pi({ model: 'anthropic/claude-sonnet-5.5', tools: createCodingTools(sandbox()) });
lib/agent.ts
import { createFxAgent } from 'libfx';
import { sandbox } from 'durable-harness';
import { fxTools } from 'durable-harness/fx';
 
export const agent = createFxAgent({ model: 'anthropic/claude-sonnet-5.5', tools: fxTools(sandbox()) });
lib/agent.ts
import { sandbox } from 'durable-harness';
import { opencode } from 'durable-harness/opencode';
 
export const agent = opencode({ model: 'anthropic/claude-sonnet-5.5', workspace: sandbox() });

On Edgenivo, the Sandbox authenticates with the deployment's OIDC token. On your machine, run vercel link and vercel env pull first: the Sandbox starts on Edgenivo, under the linked project. The Drive keeps what a command wrote even if a crash interrupts it, so an interrupted call that changes files never runs again, and the model is told it may have partly run. sandbox({ ... }) sets the Sandbox's region, network, size, and spend caps, as the Claude Code guide lists. A chat route that serves anyone needs more for an agent whose sessions start a Sandbox: see Chat routes.

With durable-harness/pi and OpenCode, a turn starts in the chat route's own request, which drives it for up to 60 seconds before the queue route takes over. A command still running at that handover is stopped, not run again, so a long command early in a turn, such as an install or a build, can be cut off. A slow first start of the session's Sandbox can run into the same handover. This is a current limitation of Edgenivo Queues, and a fix is on the way. Until then, pass drive: 'queue' to agent.chatRoute() for an agent that runs long commands: the queue route then drives the whole turn, with your plan's maximum duration.

For short tasks and small workspaces, virtual() keeps the files in the session's log, so they move with the session to any machine. It gives an agent files and a shell without the cost and startup time of a sandbox and a Drive. With durable-harness/pi, createCodingTools(virtual()) gives Pi its own coding tools on it:

lib/agent.ts
import { virtual } from 'durable-harness';
import { createCodingTools, pi } from 'durable-harness/pi'; // also install just-bash
 
export const agent = pi({ model: 'anthropic/claude-sonnet-5.5', tools: createCodingTools(virtual()) });

The agent gets Pi's read, bash, edit, and write, with Pi's names, input schemas, and descriptions, on /workspace. Add your own tools to the same array. bash runs just-bash, a bash-compatible shell with coreutils, grep, sed, awk, and jq: its commands can't reach your function's files, processes, or environment, and it has no network and no real binaries such as node or python. Each call that changes files commits the change with its own result, so a crash in the middle of a call leaves nothing behind, and the call runs again. Small files are stored in the log, and larger ones as hash-checked blobs next to it.

These tools aren't for large or long-running work. Every write is an event in the log, a resumed turn reads the history back, and a workspace holds about 500 files.

For a loop of your own, such as an AI SDK agent, workspaceTools(virtual()) from durable-harness/tools gives bash, read_file, write_file, edit_file, and list_files on the same kind of workspace, and workspaceInstructions(virtual()) returns text for your agent's instructions that says what the workspace is and how long its files last.

For scratch work within a turn, virtual({ durable: false }) gives the same tools on files in memory. Nothing goes to the log, the files are cleared at the end of every turn, and the tools' descriptions tell the model:

lib/agent.ts
import { virtual } from 'durable-harness';
import { createCodingTools, pi } from 'durable-harness/pi';
 
export const agent = pi({ model: 'anthropic/claude-sonnet-5.5', tools: createCodingTools(virtual({ durable: false })) });

A document is typed JSON state that lives in the session's log, such as a todo list, a plan, or a form the agent fills in. Because it's in the log, it's as durable as the conversation: it survives a crash and moves with the session, and a tool's edit commits together with the tool's result, so the state always matches what the agent did. Your app reads it without a database of its own, and your UI can follow it as it changes.

Define a document with a name, a schema version, and its initial value:

lib/todos.ts
import { defineDoc } from 'durable-harness';
 
export const Todos = defineDoc<{ items: string[] }>({
  kind: 'app.todos',
  version: 1,
  initial: () => ({ items: [] }),
});

A tool reads and edits documents through io.docs. The edit commits with the call's result, so a call that throws or is cut off by a crash changes nothing:

lib/tools.ts
import type { HostTool } from 'durable-harness';
import { Todos } from './todos';
 
export const addTodo: HostTool<{ item: string }> = {
  name: 'add_todo',
  description: 'Adds a todo',
  replay: 'safe',
  inputSchema: { type: 'object', properties: { item: { type: 'string' } }, required: ['item'] },
  async execute({ item }, io) {
    const todos = io.docs!.update(Todos, (draft) => {
      draft.items.push(item);
    });
    return `${todos.items.length} todos`;
  },
};

Only the tools you pass can use documents. Claude Code's and Codex's own tools don't see them.

App code, such as a route or a webhook, reads and edits documents through agent.sessions:

import { agent } from '@/lib/agent';
import { Todos } from '@/lib/todos';
 
const { value, revision } = await agent.sessions.doc(sessionId, Todos);
 
await agent.sessions.updateDoc(sessionId, Todos, (draft) => {
  draft.items.push('Buy milk');
});

updateDoc applies only on the revision it read. If another edit landed first, it reads the document again and runs your function again. Pass { expectRevision } to throw a DocConflict instead.

List documents in the chat route to stream them to the browser. Each response starts with the current value of each listed document, then sends a doc event with the whole value and its revision after every change:

app/api/chat/route.ts
import { agent } from '@/lib/agent';
import { authorize } from '@/lib/auth'; // your own check, as in the chat route step
import { Todos } from '@/lib/todos';
 
export const { GET, POST } = agent.chatRoute({ authorize, docs: [Todos] });

Show the highest revision you've seen of each document.

The model doesn't see documents unless you give it tools for them. docTools() makes a read tool and an update tool for each document:

lib/agent.ts
import { pi } from 'durable-harness/pi';
import { docTools } from 'durable-harness/tools';
import { Todos } from './todos';
 
export const agent = pi({ model: 'anthropic/claude-sonnet-5.5', tools: docTools([Todos]) }); // read_todos, update_todos

With durable-harness/pi, promptDocs: [Todos] also puts the listed documents in the instructions of every model call.

  • A document holds at most 64 KB of JSON. A write that grows it past that throws DocTooLarge.
  • To change a document's shape, bump its version and give migrate(value, fromVersion), which runs when an older document is read. A document stored at a newer version than your code's throws DocVersionError.
  • Documents work with durable-harness/pi, Claude Code, Codex, and OpenCode. durable-harness/pi-durable keeps Pi's own documents.

Was this helpful?

supported.