Harnesses
Run FX's or Pi's own loop as the agent's, durably, behind the same conversation.
Choose the loop
An agent's harness is its loop: its system prompt, its tools, and how it
decides what to do next. The default, { type: 'ai-sdk' }, runs one AI SDK
streamText() step per generation with the prompt and tools you give it.
fx() runs FX and pi() runs Pi instead,
and A2 keeps them durable. Each model call the harness makes is one step of the response,
with streamed progress, tool calls, steering, interruption, and recovery like
any other step. The conversation is the same AIState, so useAgent() renders
every harness alike.
Install
pnpm add libfx # fx()
pnpm add @earendil-works/pi-coding-agent @earendil-works/pi-ai # pi()Run FX
fx() runs FX's kernel in your server process. FX adds no system prompt of its
own and brings no tools, so instructions is the whole prompt and tools is
everything it can call:
import { tool } from 'ai'
import { Sandbox } from '@vercel/sandbox'
import { z } from 'zod'
import { handlerContext } from 'experimental-a2/ai'
import { agent } from 'experimental-a2/ai/server'
import { fx } from 'experimental-a2/ai/fx'
export const coder = agent({
name: 'coder',
harness: fx(),
model: 'anthropic/claude-sonnet-5.5',
instructions: 'Fix the bug the user describes. Keep replies short.',
tools: {
bash: tool({
description: 'Run a shell command in the repository.',
inputSchema: z.object({ command: z.string() }),
execute: async ({ command }) => {
const { session, signal } = handlerContext()
// One Vercel Sandbox per session, created on first use.
const sandbox = await Sandbox.getOrCreate({
name: session.id,
persistent: true,
signal,
})
const result = await sandbox.runCommand({
cmd: 'bash',
args: ['-c', command],
signal,
})
return { exitCode: result.exitCode, output: await result.stdout() }
},
}),
},
toolRecovery: { bash: 'report' },
})Run Pi
pi() runs Pi's own agent session: Pi's system prompt, and Pi's read,
write, edit, and bash tools. instructions adds to Pi's prompt and
tools adds to Pi's tools; a tool with the name of one of Pi's replaces it.
Pi's tools work in the session's sandbox, so pi() takes one:
import type { Experimental_SandboxSession } from 'ai'
import { agent } from 'experimental-a2/ai/server'
import { pi } from 'experimental-a2/ai/pi'
// your sandbox: the same AI SDK sandbox session for a session every time,
// for example from @ai-sdk/sandbox-vercel
declare function openSandbox(
sessionId: string,
): Promise<Experimental_SandboxSession>
export const piCoder = agent({
name: 'pi-coder',
harness: pi({ sandbox: ({ session }) => openSandbox(session.id) }),
model: { model: 'anthropic/claude-sonnet-5.5', reasoning: 'low' },
})sandbox resolves the session's sandbox, any AI SDK sandbox session, for each
tool execution, and for a step until Pi's session records the sandbox's
working directory, so return the same one for a session every time and create
it only once. Your own tools under pi()
receive it as experimental_sandbox too. A2 never stops it; stopping it is
yours. Pi works in the sandbox's working directory, and its bash runs with
the sandbox's own environment; none of your server's environment reaches it.
Pi's tools move at most 4 MiB from the sandbox to your server per file read
or command. A larger file fails read or edit, and a command whose output
passes 4 MiB is stopped and fails bash; both errors tell the model to read in
parts. Files arrive through the sandbox's readFile, so implement it as a
stream for the limit to bound the download too.
Models and credentials
Both harnesses call models through Vercel AI Gateway. model is a Gateway
model ID, or ModelSettings with one, resolved
for each step like any agent's model; pi() takes the Gateway models Pi's own
catalog lists. reasoning becomes FX's effort or Pi's thinking level, and
speed: 'fast' asks AI Gateway for the model's faster tier under either
harness. Credentials come from AI_GATEWAY_API_KEY, or from Vercel's OIDC
token on Vercel. Seeded assistant messages do not reach the harness.
Images and files
The harness reads each user message's text and its image files. Neither FX nor
Pi takes URLs, so A2 fetches each image from its https: or data: URL and
hands the harness its bytes. The fetch goes through the
AI SDK's download, which refuses private addresses, checks each redirect, and
stops past 32 MiB of one message's files. Each step of a response replays its
turn, so a process keeps the images it fetched instead of fetching them again.
A file that isn't an image, or that A2 can't fetch, fails the turn with an error that names it. So does a steer with files under FX, which steers with text only. The harness applies its own rules to the images it gets: FX fails a turn whose model doesn't take images, and Pi resizes them. Every step sends the conversation so far, files and all, so a file that fails one turn fails the turns after it too.
A2 fetches whatever URL a user message names, so check file URLs on the way
in. An agent's messageSchema can require them to point at your storage:
import type { UIMessage } from 'ai'
import { agent } from 'experimental-a2/ai/server'
import { fx } from 'experimental-a2/ai/fx'
import { z } from 'zod'
// your storage's public URL:
const uploads = 'https://uploads.example.com/'
export const reviewer = agent({
name: 'reviewer',
harness: fx(),
model: 'anthropic/claude-sonnet-5.5',
instructions: 'Review the screenshots the user sends. Keep replies short.',
messageSchema: z
.custom<UIMessage>()
.refine(
(message) =>
message.role !== 'user' ||
message.parts.every(
(part) => part.type !== 'file' || part.url.startsWith(uploads),
),
"A user's files must be uploads",
),
})What the harness owns
The harness owns its loop, its prompt, and its compaction, so generation,
generate, compaction, and messageMetadata exist only in
{ type: 'ai-sdk' }. A harness runs its tools without approval, so a tool that
sets needsApproval is rejected. It hands the engine only tools A2 runs, so a
provider-defined tool, such as a provider's web search, is rejected too. maxSteps limits the model calls in one
response under every harness. Steering enters at the next step, as with any
agent: the harness sees the steered message before its next model call.
Recovery
A2 records each model call and each tool result as the harness works. When a
process dies in the middle of a response, the next attempt resumes at the
interrupted step: finished model calls are not made again and finished tools
do not run again. A2 stores nothing outside the log: a process that ran the
last response resumes the harness from its state at the end of that response,
FX's checkpoint or Pi's session, kept in memory, and any other process replays
the session from the log. A tool call that a crash cut short reruns unless its
toolRecovery is 'report'; see Delivery semantics.