---
title: Harnesses
description: "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](https://fx.sh) and `pi()` runs [Pi](https://pi.dev) 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

```bash
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:

```ts server/coder.ts
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:

```ts server/pi-coder.ts
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`](/reference/ai#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:

```ts server/reviewer.ts
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](/agents/conversations#delivery-semantics).
