Skip to content
Docs
 

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's Harness, 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/pi runs Pi's agent loop, Agent from @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 forMost Pi agentsAgents that need only Pi's agent loop
What you writeYour Pi code, on openWorldStorage()pi({ model, tools })
Pi featuresPi's full Harness, including extensions, compaction, and forksPi's agent loop and tools
SpeedEach step waits for the log, so turns are slowerDurability adds little latency
SteeringSteering from another request takes the session overRedirect a running turn from any request
ReconnectingThe stream resends the response in progress from its startThe 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.

  1. npm i durable-harness @earendil-works/pi-durable @earendil-works/chord @earendil-works/pi-ai
  2. Write 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.ts
    import { 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 calls resumeWith.

  3. 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, openWorldStorage starts a new session, and storage.sessionId is its ID. Everything after that is Pi's own API: harness.root(), root.submit(), submission.wait(), and watchEvents().

  4. Your app sends each message to a chat route and reads the turn back from it. chatRoute is that route, ready-made: POST submits a message to Pi and streams the turn as server-sent events, GET reconnects to a turn that's still running, and it keeps the request alive until Pi's work is done. It has the same events and authorize rule as every chat route in these guides:

    app/api/chat/route.ts
    import { 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.ts
    import { 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 withSessionOwnership helper to lib/session-ownership.ts and 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.

    In production (NODE_ENV=production, which includes every Edgenivo deployment), a chat route without authorize refuses every request with a 401. To serve anyone, pass authorize: 'public'. With env: sandboxEnv(), every session starts a Sandbox, so a public route also needs allowPublicSandbox: true and a finite sandboxEnv({ budget: { sessionUsd } }), which doesn't count Pi's own model calls. Whoever can use the route runs the agent on your AI Gateway credits. See Security.

    POST { "text": "..." } to start a session, and read its ID from the x-session-id header. 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's root.submit(), and close the Harness once the submission finishes. Closing with work left hands it to the queue route.

  5. 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 let durable-harness write the same settings with harnessFunctions() in vercel.ts or with withDurableHarness on 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.ts to the queue. npx durable-harness vercel-json --write writes this entry into your vercel.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/flow and needs one vercel.json entry, which npx durable-harness vercel-json --write adds, or harnessFunctions() in vercel.ts. Without it, the build on Edgenivo fails and prints the entry.

  6. vercel deploy

    On your machine, run vercel link and vercel env pull first, 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_KEY also works. next dev (or vercel dev for 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. chatRoute handles both: the first request's stream follows the session's log to the end, and a request that lost retries once with the same requestId. In a route of your own, open the session again after a refused write, and submit again with the same requestId.
  • Background work finishes after you close. submission.wait() and waitForIdle() don't wait for background work, such as compaction or a background subagent. When you close the Harness, the queue route finishes that work. chatRoute waits 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 recoverAfterMs while 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 with openWorldStorage(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 a NodeExecutionEnv: other sessions on the same instance could reach them, and a resumed turn can run on another instance without them. Use virtualEnv(), a virtual workspace, or sandboxEnv(), the session's Edgenivo Sandbox, as Tools shows. See Security.
  1. npm i durable-harness @earendil-works/pi-agent-core @earendil-works/pi-ai
  2. Make the agent with an AI Gateway model. To give it tools, see Tools:

    lib/agent.ts
    import { pi } from 'durable-harness/pi';
     
    export const agent = pi({ model: 'anthropic/claude-sonnet-5.5' });
  3. POST sends a message and streams its turn as server-sent events, and GET reconnects to a turn that's still running:

    app/api/chat/route.ts
    import { 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.ts
    import { 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 withSessionOwnership helper to lib/session-ownership.ts and 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.

    In production (NODE_ENV=production, which includes every Edgenivo deployment), a chat route without authorize refuses every request with a 401. To serve anyone, pass authorize: 'public'. With createCodingTools(sandbox()), every session starts a Sandbox, so a public route also needs allowPublicSandbox: true and a finite sandbox({ budget: { sessionUsd } }), which doesn't count Pi's own model calls. Whoever can use the route runs the agent on your AI Gateway credits. See Security.

    POST { "text": "..." } to start a session, and read its ID from the x-session-id header. 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.

  4. 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 let durable-harness write the same settings with harnessFunctions() in vercel.ts or with withDurableHarness on 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.ts to the queue. npx durable-harness vercel-json --write writes this entry into your vercel.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/flow and needs one vercel.json entry, which npx durable-harness vercel-json --write adds, or harnessFunctions() in vercel.ts. Without it, the build on Edgenivo fails and prints the entry.

  5. vercel deploy

    On your machine, run vercel link and vercel env pull first, 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_KEY also works. next dev (or vercel dev for 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 turn

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

lib/pi.ts
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:

lib/agents.ts
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():

lib/pi.ts
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:

lib/agent.ts
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:

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

lib/pi.ts
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 }).

lib/agent.ts
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-turn

An 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?

supported.