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:

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:

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

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.