Skip to content
Docs

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.

These integrations are experimental: durable-harness is in beta. Package names and APIs can change before they're stable.

durable-harness chooses the World the way the Workflow SDK does:

Where the app runsWorldSessions live in
On Edgenivo@workflow/world-vercelEdgenivo Workflows storage
Anywhere else@workflow/world-localFiles in WORKFLOW_LOCAL_DATA_DIR, by default .workflow-data
Anywhere, with WORKFLOW_TARGET_WORLD setThe World it names: vercel, local, or a package such as @workflow/world-postgresThat 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.

  1. npm i @workflow/world-postgres

    Use @workflow/world-postgres 5.0.4 or later.

  2. WORKFLOW_POSTGRES_URL="postgres://user:password@host:5432/database" npx --package=@workflow/world-postgres bootstrap

    It's safe to run again. Run it again after you upgrade the package.

  3. .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 reads DATABASE_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.

  4. withDurableHarness already 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 in instrumentation.ts. Import every module that makes an agent first, so the World's deliveries reach it:

    instrumentation.ts
    export 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.json and vercel.ts don'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-server
    server.ts
    import { 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.

    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'. Whoever can use the route runs the agent on your AI Gateway credits.

  5. next build && next start

    In an ES module project ("type": "module" in package.json), run it with your .env loaded. For example, install tsx with npm i -D tsx and run node --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:

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 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 with registerQueue(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 sends durable-harness's queue to your app's queue route, and runs none of it:
worker.ts
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, or agent.handler for an agent with its own World. On Next.js without the Workflow SDK, withDurableHarness serves 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 set DURABLE_HARNESS_FLOW_ROUTE in 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:

lib/agent.ts
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_PREFIX doesn'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?

supported.