Running Pi Durably on Edgenivo
Pi is an open-source agent harness. durable-harness makes it durable on Edgenivo in one of two ways, and in both, a turn that loses its function continues in a new one. We recommend durable-harness/pi-durable. To learn how durability works, see Running Agent Harnesses Durably.
durable-harness/pi-durable(recommended) runs Pi 1.0'sHarness, from@earendil-works/pi-durable, on a storage in the log, so each of Pi's commits is an event in the log. You write Pi's own code.durable-harness/piruns Pi's agent loop,Agentfrom@earendil-works/pi-agent-core, and records each model call and tool call as a step in the log.
durable-harness/pi-durable (recommended) | durable-harness/pi | |
|---|---|---|
| Best for | Most Pi agents | Agents that need only Pi's agent loop |
| What you write | Your Pi code, on openWorldStorage() | pi({ model, tools }) |
| Pi features | Pi's full Harness, including extensions, compaction, and forks | Pi's agent loop and tools |
| Speed | Each step waits for the log, so turns are slower | Durability adds little latency |
| Steering | Steering from another request takes the session over | Redirect a running turn from any request |
| Reconnecting | The stream resends the response in progress from its start | The stream continues from the last event |
Pick durable-harness/pi-durable. It's Pi 1.0's full Harness, so your agent gets Pi's extensions, compaction, and forks, and if you already have Pi code, only the storage changes. Pick durable-harness/pi for Pi's agent loop alone: durability adds less latency, and it works like every other harness in these guides.
npm i durable-harness @earendil-works/pi-durable @earendil-works/chord @earendil-works/pi-aiWrite your Pi code as Pi's documentation shows, or keep the code you have. In a module of its own, pass the function that opens your Harness to
resumeWith, so the queue route can reopen a session after a crash:lib/pi.tsimport { BACKGROUND_CONTEXT as ctx } from '@earendil-works/chord/context'; import { createRegistry, Harness, type Storage } from '@earendil-works/pi-durable'; import { gatewayModels, GATEWAY_PROVIDER, resumeWith } from 'durable-harness/pi-durable'; const models = gatewayModels(); // or your own pi-ai models const registry = createRegistry(); // your tools and extensions 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 }, ctx));gatewayModels()gives Pi its models through AI Gateway with the deployment's credentials. A build on Edgenivo fails if the app opens pi-durable sessions and no module callsresumeWith.Where Pi would open SQLite, JSONL, or memory storage, open the session's storage on the World:
import { openWorldStorage } from 'durable-harness/pi-durable'; import { open } from './pi'; const storage = await openWorldStorage(sessionId); const harness = await open(storage);Without an ID,
openWorldStoragestarts a new session, andstorage.sessionIdis its ID. Everything after that is Pi's own API:harness.root(),root.submit(),submission.wait(), andwatchEvents().Your app sends each message to a chat route and reads the turn back from it.
chatRouteis that route, ready-made:POSTsubmits a message to Pi and streams the turn as server-sent events,GETreconnects to a turn that's still running, and it keeps the request alive until Pi's work is done. It has the same events andauthorizerule as every chat route in these guides:app/api/chat/route.tsimport { chatRoute, openWorldStorage } from 'durable-harness/pi-durable'; import { withSessionOwnership } from '@/lib/session-ownership'; import { agent, ctx, open } from '@/lib/pi'; export const { GET, POST } = withSessionOwnership({ createRoute: (authorize) => chatRoute({ open, agent, authorize }), start: async () => { const storage = await openWorldStorage(undefined, { traceAudience: 'unknown' }); const sessionId = storage.sessionId; await storage.close(ctx); return { sessionId }; }, });api/chat.tsimport { chatRoute, openWorldStorage } from 'durable-harness/pi-durable'; import { withSessionOwnership } from '../lib/session-ownership.js'; import { agent, ctx, open } from '../lib/pi.js'; export const { GET, POST } = withSessionOwnership({ createRoute: (authorize) => chatRoute({ open, agent, authorize }), start: async () => { const storage = await openWorldStorage(undefined, { traceAudience: 'unknown' }); const sessionId = storage.sessionId; await storage.close(ctx); return { sessionId }; }, });Add the
withSessionOwnershiphelper tolib/session-ownership.tsand implement its authentication and persistent ownership store. It records the owner before submitting a session's first message, and checks ownership on later messages and reconnects.POST
{ "text": "..." }to start a session, and read its ID from thex-session-idheader. Send{ "sessionId": "...", "text": "..." }for the next message. To redirect a running turn or reconnect to one, see Steer a running turn and Reconnect to a running turn.If your app already has a route that runs Pi, you can keep it instead: open the session with
openWorldStorage, submit with Pi'sroot.submit(), and close the Harness once the submission finishes. Closing with work left hands it to the queue route.Edgenivo Queues delivers each turn to a queue route that runs it, with your plan's maximum duration. Set it up in
vercel.json, or letdurable-harnesswrite the same settings withharnessFunctions()invercel.tsor withwithDurableHarnesson Next.js:api/flow.ts// A Edgenivo Function in your api directory. It imports the module that // calls resumeWith, so it can run the agent's turns. import '../lib/pi.js'; import { flowHandler } from 'durable-harness'; export const POST = flowHandler();vercel.json{ "functions": { "api/flow.ts": { "maxDuration": "max", "experimentalTriggers": [ { "type": "queue/v2beta", "topic": "__harness_wkf_workflow_*", "retryAfterSeconds": 5, "initialDelaySeconds": 0 } ] } } }The trigger subscribes
api/flow.tsto the queue.npx durable-harness vercel-json --writewrites this entry into yourvercel.json.api/flow.ts// A Edgenivo Function in your api directory. It imports the module that // calls resumeWith, so it can run the agent's turns. import '../lib/pi.js'; import { flowHandler } from 'durable-harness'; export const POST = flowHandler();vercel.ts// The same settings as in vercel.json, worked out from the packages you // installed when the project builds. Install @vercel/config as a dev // dependency. import type { VercelConfig } from '@vercel/config/v1'; import { harnessFunctions } from 'durable-harness/vercel'; export const config: VercelConfig = { functions: harnessFunctions(), };next.config.ts// Writes the queue route and its trigger, and keeps the harness packages // out of the server bundle. You don't add a queue route or a vercel.json. import type { NextConfig } from 'next'; import { withDurableHarness } from 'durable-harness/next'; const nextConfig: NextConfig = { // your existing config }; export default withDurableHarness(nextConfig);If the app also uses the Workflow SDK, wrap both, as in
withWorkflow(withDurableHarness(nextConfig)). The queue route then moves to/.well-known/durable-harness/v1/flowand needs onevercel.jsonentry, whichnpx durable-harness vercel-json --writeadds, orharnessFunctions()invercel.ts. Without it, the build on Edgenivo fails and prints the entry.vercel deployOn your machine, run
vercel linkandvercel env pullfirst, so model calls, and a Sandbox if your tools use one, authenticate with your project's OIDC token. For model calls alone,AI_GATEWAY_API_KEYalso works.next dev(orvercel devfor other frameworks) keeps sessions in.workflow-data, so add it to.gitignore. On its first request, it resumes the sessions a stopped server left mid-turn.
- A request that opens a session takes it over. The request that had it open stops at its next write: the World refuses the write, so nothing more of that request lands. The new request continues the turn from where the agent was. If two requests open a session at the same time, the first to write keeps it.
chatRoutehandles both: the first request's stream follows the session's log to the end, and a request that lost retries once with the samerequestId. In a route of your own, open the session again after a refused write, and submit again with the samerequestId. - Background work finishes after you close.
submission.wait()andwaitForIdle()don't wait for background work, such as compaction or a background subagent. When you close the Harness, the queue route finishes that work.chatRoutewaits for it. - A request stays alive while its work runs. It ends when the work is done, or after
recoverAfterMs(120 seconds by default) with no commit. - A long silence can look like a crash. If a session commits nothing for
recoverAfterMswhile it has unfinished work, another function can take it over. A tool that writes no output or a model call that streams nothing can do that, so raise the limit for them withopenWorldStorage(sessionId, { recoverAfterMs }). - Give Pi's tools a workspace, not your function. Pi's
env, where its tools run, isn't saved with the session. Don't give it your function's directory or processes, such as aNodeExecutionEnv: other sessions on the same instance could reach them, and a resumed turn can run on another instance without them. UsevirtualEnv(), a virtual workspace, orsandboxEnv(), the session's Edgenivo Sandbox, as Tools shows. See Security.
npm i durable-harness @earendil-works/pi-agent-core @earendil-works/pi-aiMake the agent with an AI Gateway model. To give it tools, see Tools:
lib/agent.tsimport { pi } from 'durable-harness/pi'; export const agent = pi({ model: 'anthropic/claude-sonnet-5.5' });POSTsends a message and streams its turn as server-sent events, andGETreconnects to a turn that's still running:app/api/chat/route.tsimport { agent } from '@/lib/agent'; import { withSessionOwnership } from '@/lib/session-ownership'; export const { GET, POST } = withSessionOwnership({ createRoute: (authorize) => agent.chatRoute({ authorize }), start: () => agent.sessions.start({ config: { traceAudience: 'unknown' } }), });api/chat.tsimport { agent } from '../lib/agent.js'; import { withSessionOwnership } from '../lib/session-ownership.js'; export const { GET, POST } = withSessionOwnership({ createRoute: (authorize) => agent.chatRoute({ authorize }), start: () => agent.sessions.start({ config: { traceAudience: 'unknown' } }), });Add the
withSessionOwnershiphelper tolib/session-ownership.tsand implement its authentication and persistent ownership store. It records the owner before submitting a session's first message, and checks ownership on later messages and reconnects.POST
{ "text": "..." }to start a session, and read its ID from thex-session-idheader. Send{ "sessionId": "...", "text": "..." }for the next message. To redirect a running turn or reconnect to one, see Steer a running turn and Reconnect to a running turn.Edgenivo Queues delivers each turn to a queue route that runs it, with your plan's maximum duration. Set it up in
vercel.json, or letdurable-harnesswrite the same settings withharnessFunctions()invercel.tsor withwithDurableHarnesson Next.js:api/flow.ts// A Edgenivo Function in your api directory. It imports the module that // makes your agent, so it can run the agent's turns. import '../lib/agent.js'; import { flowHandler } from 'durable-harness'; export const POST = flowHandler();vercel.json{ "functions": { "api/flow.ts": { "maxDuration": "max", "experimentalTriggers": [ { "type": "queue/v2beta", "topic": "__harness_wkf_workflow_*", "retryAfterSeconds": 5, "initialDelaySeconds": 0 } ] } } }The trigger subscribes
api/flow.tsto the queue.npx durable-harness vercel-json --writewrites this entry into yourvercel.json.api/flow.ts// A Edgenivo Function in your api directory. It imports the module that // makes your agent, so it can run the agent's turns. import '../lib/agent.js'; import { flowHandler } from 'durable-harness'; export const POST = flowHandler();vercel.ts// The same settings as in vercel.json, worked out from the packages you // installed when the project builds. Install @vercel/config as a dev // dependency. import type { VercelConfig } from '@vercel/config/v1'; import { harnessFunctions } from 'durable-harness/vercel'; export const config: VercelConfig = { functions: harnessFunctions(), };next.config.ts// Writes the queue route and its trigger, and keeps the harness packages // out of the server bundle. You don't add a queue route or a vercel.json. import type { NextConfig } from 'next'; import { withDurableHarness } from 'durable-harness/next'; const nextConfig: NextConfig = { // your existing config }; export default withDurableHarness(nextConfig);If the app also uses the Workflow SDK, wrap both, as in
withWorkflow(withDurableHarness(nextConfig)). The queue route then moves to/.well-known/durable-harness/v1/flowand needs onevercel.jsonentry, whichnpx durable-harness vercel-json --writeadds, orharnessFunctions()invercel.ts. Without it, the build on Edgenivo fails and prints the entry.vercel deployOn your machine, run
vercel linkandvercel env pullfirst, so model calls, and a Sandbox if your tools use one, authenticate with your project's OIDC token. For model calls alone,AI_GATEWAY_API_KEYalso works.next dev(orvercel devfor other frameworks) keeps sessions in.workflow-data, so add it to.gitignore. On its first request, it resumes the sessions a stopped server left mid-turn.
Send a message with "kind": "steer" while a turn runs to redirect it. From the browser, post it to the chat route:
await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId, text: 'Use TypeScript instead', kind: 'steer' }),
});The response streams the turn, like any other message.
Pi takes the steer after the tool calls that are running. A steer from another request takes the session over, and that request continues the turn. From server code, such as a webhook, submit with Pi's whenBusy: 'steer':
import { openWorldStorage } from 'durable-harness/pi-durable';
import { agent, ctx, open } from './pi';
const harness = await open(await openWorldStorage(sessionId));
const root = await harness.root(ctx, { agent });
await root.submit(
{ type: 'input', content: 'Use TypeScript instead', requestId: crypto.randomUUID(), whenBusy: 'steer' },
ctx,
);
await harness.close(ctx); // the queue route finishes the turnThe steer reaches the model at the turn's next model call, whichever request sends it. From server code, such as a webhook, call agent.sessions.send:
import { agent } from './agent';
await agent.sessions.send(sessionId, 'Use TypeScript instead', 'steer');If the stream drops, the turn keeps running.
With chatRoute, reconnect without a cursor. The response starts again from the beginning of the model response in progress, so replace the text you showed for it. A rewind event means a model call restarted, so drop its text. When the session is idle, GET answers 204:
const source = new EventSource(`/api/chat?sessionId=${sessionId}`);
source.onmessage = (event) => render(JSON.parse(event.data)); // your own UI
source.addEventListener('end', () => source.close());Reconnect with GET /api/chat?sessionId=...&cursor=..., where the cursor is the last event id you read. The stream continues with the next event, with nothing lost or repeated. An EventSource on that URL reconnects by itself if its connection drops, and the stream ends with an end event when the turn is done:
const source = new EventSource(`/api/chat?sessionId=${sessionId}&cursor=${lastEventId}`);
source.onmessage = (event) => {
lastEventId = event.lastEventId;
render(JSON.parse(event.data)); // your own UI
};
source.addEventListener('end', () => source.close());An app can make several agents, each with its own chat route. The queue route serves them all.
Give each Pi setup a name, and pass it to resumeWith:
const openHarness = (storage: Storage) => Harness.open(storage, { models, registry }, ctx);
export const openReviewer = resumeWith(openHarness, { name: 'reviewer' });
export const openFixer = resumeWith(openHarness, { name: 'fixer' });Open each one's sessions with the same name, as in openWorldStorage(sessionId, { name: 'reviewer' }), or chatRoute({ open: openReviewer, agent, name: 'reviewer', authorize }). Keep each name stable, because sessions that already started carry it.
Give each agent a name:
import { pi } from 'durable-harness/pi';
export const reviewer = pi({ name: 'reviewer', model: 'anthropic/claude-opus-5.5' });
export const fixer = pi({ name: 'fixer', model: 'anthropic/claude-sonnet-5.5' });Keep each name stable, because sessions that already started carry it. Two agents with the same name throw when their module loads, so the build fails.
A Harness has no tools until you install them in your registry. Install Pi's own read, write, edit, and bash with CodingTools from durable-harness/pi-durable, and give them a workspace with virtualEnv(). Add tools of your own with Pi's defineTool():
import { BACKGROUND_CONTEXT as ctx } from '@earendil-works/chord/context';
import { Type } from '@earendil-works/pi-ai';
import { createRegistry, defineExtension, defineTool, Harness, type Storage } from '@earendil-works/pi-durable';
import { CodingTools, gatewayModels, GATEWAY_PROVIDER, resumeWith, virtualEnv } from 'durable-harness/pi-durable';
import { getOrder } from './orders'; // your own code
const getOrderTool = defineTool({
name: 'get_order',
description: 'Looks up an order',
replay: 'safe', // safe to run again after a crash
parameters: Type.Object({ id: Type.String() }),
execute: async (args, api) => {
api.output(JSON.stringify(await getOrder(args.id)));
return {};
},
});
const models = gatewayModels();
const registry = createRegistry();
registry.install(CodingTools); // Pi's read, write, edit, and bash
registry.install(defineExtension({ name: 'orders', tools: [getOrderTool] }));
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: virtualEnv() }, ctx),
);virtualEnv() is a virtual workspace at /workspace. Each call's file changes commit with its result in the session's log, so the files follow the session to any function. Also install just-bash. After a crash, a 'safe' tool runs again, and any other comes back to the model as interrupted.
To run the same tools in the session's own Edgenivo Sandbox, pass sandboxEnv() instead, and install @vercel/sandbox:
import { sandboxEnv } from 'durable-harness/pi-durable';
export const open = resumeWith((storage: Storage) =>
Harness.open(storage, { models, registry, env: sandboxEnv() }, ctx),
);The files are on a Drive at /workspace that lasts across turns, and the Sandbox starts at the first tool call. Pi itself stays in your function. A command that a crash interrupts may have partly run, so it comes back to the model as interrupted and never runs again. On your machine, run vercel link and vercel env pull first: the Sandbox starts on Edgenivo, under the linked project.
durable-harness/pi starts with no tools. Pass Pi AgentTools or HostTools as tools, each with a replay rule. createCodingTools(virtual()) adds Pi's own read, bash, edit, and write on a virtual workspace:
import { virtual, type HostTool } from 'durable-harness';
import { createCodingTools, pi } from 'durable-harness/pi'; // also install just-bash
import { getOrder } from './orders'; // your own code
const getOrderTool: 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)),
};
export const agent = pi({
model: 'anthropic/claude-sonnet-5.5',
tools: [...createCodingTools(virtual()), getOrderTool],
});After a crash, a 'safe' tool runs again, and any other comes back to the model as interrupted.
To run Pi's tools in the session's own Edgenivo Sandbox, pass sandbox() instead, and install @vercel/sandbox:
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()) });The files are on a Drive at /workspace that lasts across turns, and the Sandbox starts at the first tool call. Pi itself stays in your function. What a call wrote stays on the Drive even if a crash interrupts it, so after a crash read runs again, and bash, edit, and write come back to the model as interrupted. On your machine, run vercel link and vercel env pull first: the Sandbox starts on Edgenivo, under the linked project. A long command early in a turn can be cut off: see A Edgenivo Sandbox with a Drive.
For files, documents, and how to write tools safely, see Tools, files, and documents.
Both variants read finished steps back from the log, run the model calls and 'safe' tools that were running again, and return an interrupted 'never' tool to the model as possibly run. If the function running a turn fails with an error, another function resumes the turn right away. If it disappears without a trace, for example when it runs out of memory, the queue redelivers the turn once its message lease runs out, and the turn resumes from the log. With durable-harness/pi-durable, the storage stands behind its own commits: a turn that dies silently resumes within recoverAfterMs of its last commit, and right away if the Harness closed with work left.
Both variants keep sessions on the environment's World: Edgenivo's World on Edgenivo, and .workflow-data on your machine. To keep them in another World, such as Postgres on your own servers, install it and set WORKFLOW_TARGET_WORLD:
WORKFLOW_TARGET_WORLD="@workflow/world-postgres"
WORKFLOW_POSTGRES_URL="postgres://user:password@host:5432/database"To use a World you create, pass it to durable-harness and start it yourself:
import { BACKGROUND_CONTEXT as ctx } from '@earendil-works/chord/context';
import { createRegistry, Harness, type Storage } from '@earendil-works/pi-durable';
import { createWorld } from '@workflow/world-postgres';
import { registerQueue } from 'durable-harness';
import { gatewayModels, GATEWAY_PROVIDER, resumeWith } from 'durable-harness/pi-durable';
const models = gatewayModels();
const registry = createRegistry();
export const world = createWorld({ connectionString: process.env.WORKFLOW_POSTGRES_URL! });
export const agent = { model: { provider: GATEWAY_PROVIDER, modelId: 'anthropic/claude-sonnet-5.5' } };
export const open = resumeWith((storage: Storage) => Harness.open(storage, { models, registry }, ctx), { world });
registerQueue(world); // before start: deliver durable-harness's queue to this process
await world.start();Open its sessions on the same World, with openWorldStorage(sessionId, { world }) or chatRoute({ open, agent, world, authorize }).
import { createWorld } from '@workflow/world-postgres';
import { recoverSessions, registerQueue } from 'durable-harness';
import { pi } from 'durable-harness/pi';
export const world = createWorld({ connectionString: process.env.WORKFLOW_POSTGRES_URL! });
export const agent = pi({ model: 'anthropic/claude-sonnet-5.5', world });
registerQueue(world); // before start: deliver durable-harness's queue to this process
await world.start();
await recoverSessions(world); // resume the sessions a stopped server left mid-turnAn agent with its own World takes its queue deliveries through agent.handler, so mount agent.handler instead of flowHandler() at the queue route.
For the tables, the server, and recovery at startup, see Self-hosting durable agent harnesses.
Was this helpful?