Self-Hosting Durable Agent Harnesses
A durable harness keeps its sessions behind a World, the storage and queue interface of the open-source Workflow SDK. On Edgenivo, that's Edgenivo's World. To run the harnesses on your own servers, or to keep sessions in your own database, choose another World. This page covers Pi, Claude Code, Codex, and OpenCode with durable-harness, and fx with libfx. For eve, see Self-host eve.
durable-harness chooses the World the way the Workflow SDK does:
| Where the app runs | World | Sessions live in |
|---|---|---|
| On Edgenivo | @workflow/world-vercel | Edgenivo Workflows storage |
| Anywhere else | @workflow/world-local | Files in WORKFLOW_LOCAL_DATA_DIR, by default .workflow-data |
Anywhere, with WORKFLOW_TARGET_WORLD set | The World it names: vercel, local, or a package such as @workflow/world-postgres | That World's storage |
The local World keeps sessions in files on one machine. It suits development, and a single server whose disk persists. To run more than one server, or to keep sessions in a database, use the Postgres World. fx chooses its World on its own: see fx.
Use Node.js 22 or later and a Postgres database.
npm i @workflow/world-postgresUse
@workflow/world-postgres5.0.4 or later.WORKFLOW_POSTGRES_URL="postgres://user:password@host:5432/database" npx --package=@workflow/world-postgres bootstrapIt's safe to run again. Run it again after you upgrade the package.
- .env
WORKFLOW_TARGET_WORLD="@workflow/world-postgres" WORKFLOW_POSTGRES_URL="postgres://user:password@host:5432/database" AI_GATEWAY_API_KEY="your-ai-gateway-api-key"Without
WORKFLOW_POSTGRES_URL, the World readsDATABASE_URL. Create an AI Gateway API key for model calls outside Edgenivo. This key does not authenticate Edgenivo Sandbox requests; those need separate Sandbox credentials. withDurableHarnessalready serves the queue route. To resume the sessions a stopped server left mid-turn as soon as the server starts, rather than on its first request, start the World ininstrumentation.ts. Import every module that makes an agent first, so the World's deliveries reach it:instrumentation.tsexport async function register() { if (process.env.NEXT_RUNTIME !== 'nodejs') return; await import('./lib/agent'); // every module that makes an agent const { getWorld } = await import('durable-harness'); await getWorld(); }Serve the chat route and the queue route from any server that takes a Fetch API
Request.vercel.jsonandvercel.tsdon't apply on your own server, so it mounts the queue route itself. This one uses Hono's Node.js server and the session ownership helper to authenticate callers and save each new session's owner before submitting a message:npm i hono @hono/node-serverserver.tsimport { serve } from '@hono/node-server'; import { FLOW_ROUTE, flowHandler, getWorld } from 'durable-harness'; import { agent } from './lib/agent.js'; import { withSessionOwnership } from './lib/session-ownership.js'; const chat = withSessionOwnership({ createRoute: (authorize) => agent.chatRoute({ authorize }), start: () => agent.sessions.start({ config: { traceAudience: 'unknown' } }), }); const flow = flowHandler(); await getWorld(); // start the World before the first request serve({ port: Number(process.env.PORT ?? 3000), fetch(request) { const { pathname } = new URL(request.url); if (pathname === '/api/chat') return request.method === 'POST' ? chat.POST(request) : chat.GET(request); if (pathname === FLOW_ROUTE) return flow(request); // the queue route return new Response('Not found', { status: 404 }); }, });await getWorld()creates the World, consumes the queue in this process, and resumes the sessions a stopped server left mid-turn. Without it, that happens on the first request that reaches an agent.next build && next startIn an ES module project (
"type": "module"inpackage.json), run it with your.envloaded. For example, install tsx withnpm i -D tsxand runnode --env-file=.env --import tsx server.ts, or compile it first.You can run several servers on one database: each one consumes the queue, and the World lets one server at a time drive a session.
Every durable-harness factory takes a world, so you can create the World in your own code. A World you create is yours to start: register durable-harness's queue on it, start it, and resume the sessions a stopped server left:
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 its own agent.handler, so mount agent.handler instead of flowHandler() at the queue route.
Every process that starts the World on your database does one of two things before world.start(), which re-enqueues every open session:
- It makes your agents. It imports the module that creates them and starts the World with
getWorld(), or withregisterQueue(world)for a World you pass in, so it runs their turns itself. - It forwards the queue. A process that makes no agents, such as a queue worker of its own or the Workflow SDK's worker, calls
forwardQueue(world). It sendsdurable-harness's queue to your app's queue route, and runs none of it:
import { createWorld } from '@workflow/world-postgres';
import { forwardQueue } from 'durable-harness';
const world = createWorld(); // reads WORKFLOW_POSTGRES_URL
forwardQueue(world); // before start(), which re-enqueues every open session
await world.start();Set WORKFLOW_LOCAL_BASE_URL in the worker to your app's base URL, or PORT when the app runs on the same machine. A process that starts the World and does neither sends open sessions to the Workflow SDK's queue route, which refuses them.
- Mount the queue route where the worker sends it. Your app serves
flowHandler()at/.well-known/durable-harness/v1/flow(FLOW_ROUTE), as the server above does, oragent.handlerfor an agent with its own World. On Next.js without the Workflow SDK,withDurableHarnessserves it at/.well-known/workflow/v1/flow, so pass that path:forwardQueue(world, { route: '/.well-known/workflow/v1/flow' }). For a path of your own, pass{ route }, or setDURABLE_HARNESS_FLOW_ROUTEin both processes. - A worker can start before your app. Its deliveries fail and are retried, so open sessions resume late, once the app is back.
- Protect the queue route. The Postgres World doesn't authenticate its deliveries. Restrict the route at your network edge, such as a reverse proxy or firewall, and leave
/.well-known/durable-harness/out of middleware matchers. - One server drives a session at a time. When a second server starts driving a session, the World refuses the first one's next write, so the first one stops.
durable-harness/pi-durable uses the environment's World, as above. To pass your own, give it to both resumeWith and openWorldStorage. durable-harness/pi works as above:
import { createWorld } from '@workflow/world-postgres';
import { openWorldStorage, resumeWith } from 'durable-harness/pi-durable';
export const world = createWorld({ connectionString: process.env.WORKFLOW_POSTGRES_URL! });
export const open = resumeWith((storage: Storage) => Harness.open(storage, { models, registry }, ctx), { world });
const storage = await openWorldStorage(sessionId, { world });OpenCode works as above. Its own tools work only on a virtual workspace or a Edgenivo Sandbox, never on your server's disk. Claude Code and Codex run only in a Edgenivo Sandbox, on your own server too: your server drives the turn and writes the session's log, and the CLI runs in a Sandbox with the session's files on a Drive. Give sandbox() a Edgenivo access token and the team and project its Sandboxes belong to, as the Claude Code guide shows. See Security.
libfx chooses where sessions live by itself: Edgenivo's World on Edgenivo, and files in FX_SESSIONS_DIR, by default $TMPDIR/libfx/sessions, in Node.js elsewhere. It doesn't read WORKFLOW_TARGET_WORLD. To keep sessions in another World, pass it to world() from libfx/durable-world:
import { createFxAgent } from 'libfx';
import { world } from 'libfx/durable-world';
import { createWorld } from '@workflow/world-postgres';
import { tools } from './tools';
// libfx's own queue topics and jobs, apart from the rest of the app.
const sessions = createWorld({ connectionString: process.env.WORKFLOW_POSTGRES_URL!, namespace: 'libfx', jobPrefix: 'libfx_' });
await sessions.start();
export const agent = createFxAgent({
model: 'anthropic/claude-sonnet-5.5',
tools,
durability: world(sessions, { queueDurable: true }),
});libfx never starts or closes a World you pass in, so start it before the agent's first prompt, and close it after agent.close(). The Postgres World delivers to the agent in its process. For a World that delivers over HTTP, mount agent.wakeHandler() where it delivers.
eve selects its World in agent.ts. See Self-host eve.
- Give each app its own database or data directory, and its own worker. A server that starts resumes every open session it finds, so apps that share one would run each other's sessions with their own agents. A separate
WORKFLOW_POSTGRES_JOB_PREFIXdoesn't keep two apps apart. - The Postgres World is a reference implementation. It doesn't authenticate its routes or encrypt its data, and it isn't tuned for scale. Read its documentation before you run it in production.
- The Postgres World runs its queue worker in a long-lived process, so it doesn't run in Edgenivo Functions. On Edgenivo, use Edgenivo's World.
Was this helpful?