Running fx Durably on Edgenivo
fx is an agent harness with its kernel written in Zig. Its JavaScript package, libfx, is durable by default: it saves each step of a turn as it runs, and the same code runs on your machine and on Edgenivo. To learn how durability works, see Running Agent Harnesses Durably.
libfxincludes its native addon and Edgenivo's World, so you don't install anything else:npm i libfxCreate the agent, and set
idempotenton every tool:lib/agent.tsimport { createFxAgent } from 'libfx'; import { getOrder, refundOrder } from './orders'; // your own code const tools = [ { name: 'get_order', description: 'Looks up an order', idempotent: true, // safe to run again after a crash inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, execute: (input: { id: string }) => getOrder(input.id), }, { name: 'refund_order', description: 'Refunds an order', idempotent: false, // never runs twice inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, // executionId is the same each time this call runs: use it as an idempotency key execute: (input: { id: string }, { executionId }: { executionId: string }) => refundOrder(input.id, { idempotencyKey: executionId }), }, ]; export const agent = createFxAgent({ model: 'anthropic/claude-sonnet-5.5', tools });POSTstores the prompt and streams the turn. When no other function holds the session, the same function runs the turn, and Edgenivo Queues keeps the prompt in case that function stops.GETreconnects from acursor:app/api/chat/route.tsimport { agent } from '@/lib/agent'; import { getUser, ownsSession, saveSession } from '@/lib/auth'; // your own auth and session store export async function POST(request: Request) { const user = await getUser(request); if (!user) return new Response('Unauthorized', { status: 401 }); let sessionId = new URL(request.url).searchParams.get('sessionId'); if (sessionId) { if (!(await ownsSession(user, sessionId))) return new Response('Not found', { status: 404 }); } else { sessionId = await agent.newSessionId(); await saveSession(user, sessionId); // so this user can use it later } const turn = agent.session(sessionId).prompt(await request.text()); return new Response(turn.readable); } export async function GET(request: Request) { const params = new URL(request.url).searchParams; const sessionId = params.get('sessionId'); const user = await getUser(request); if (!user || !sessionId || !(await ownsSession(user, sessionId))) return new Response('Not found', { status: 404 }); const stream = agent.session(sessionId).stream(Number(params.get('cursor') ?? 0)); return new Response(stream); }api/chat.tsimport { agent } from '../lib/agent.js'; import { getUser, ownsSession, saveSession } from '../lib/auth.js'; // your own auth and session store export async function POST(request: Request) { const user = await getUser(request); if (!user) return new Response('Unauthorized', { status: 401 }); let sessionId = new URL(request.url).searchParams.get('sessionId'); if (sessionId) { if (!(await ownsSession(user, sessionId))) return new Response('Not found', { status: 404 }); } else { sessionId = await agent.newSessionId(); await saveSession(user, sessionId); // so this user can use it later } const turn = agent.session(sessionId).prompt(await request.text()); return new Response(turn.readable); } export async function GET(request: Request) { const params = new URL(request.url).searchParams; const sessionId = params.get('sessionId'); const user = await getUser(request); if (!user || !sessionId || !(await ownsSession(user, sessionId))) return new Response('Not found', { status: 404 }); const stream = agent.session(sessionId).stream(Number(params.get('cursor') ?? 0)); return new Response(stream); }Every route checks who's calling, and that the session is theirs: a route that serves anyone runs turns on your AI Gateway credits, and lets anyone with a session ID read or steer it. See Security.
The response streams the turn as JSON lines. The first line,
turn_start, carries the session's ID, and every line has acursor. To redirect a running turn or reconnect to one, see Steer a running turn and Reconnect to a running turn.Edgenivo Queues delivers every turn the chat route doesn't run, and the rest of any turn it couldn't finish, to a queue route that runs it, with your plan's maximum duration. Set it up in
vercel.jsonorvercel.ts, or letwithDurableHarnesswrite it on Next.js:api/libfx.tsimport { agent } from '../lib/agent.js'; export const POST = agent.wakeHandler();vercel.json{ "functions": { "api/libfx.ts": { "maxDuration": "max", "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "__libfx_wkf_workflow_session" }] } } }The trigger subscribes
api/libfx.tsto the queue. On Next.js, you can put the route inapp/api/libfx/route.tsinstead, and use that path as the key.api/libfx.tsimport { agent } from '../lib/agent.js'; export const POST = agent.wakeHandler();vercel.ts// Install @vercel/config as a dev dependency. import type { VercelConfig } from '@vercel/config/v1'; export const config: VercelConfig = { functions: { 'api/libfx.ts': { maxDuration: 'max', experimentalTriggers: [{ type: 'queue/v2beta', topic: '__libfx_wkf_workflow_session' }], }, }, };The trigger subscribes
api/libfx.tsto the queue. On Next.js, you can put the route inapp/api/libfx/route.tsinstead, and use that path as the key.If your app uses Next.js,
durable-harnesscan write the queue route for you. Install it next tolibfx:npm i durable-harnessnext.config.ts// Writes the queue route and its trigger, and keeps libfx's native addon // 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);The route reaches each agent through its module's exports, so export every agent at the top level of its module, as
lib/agent.tsdoes. If yourvercel.jsonorvercel.tsalready subscribes a route to libfx's topic,withDurableHarnessleaves that route in place. In an app that also uses the Workflow SDK'swithWorkflow, wrap both, and add thevercel.jsonentries thatnpx durable-harness vercel-json --writeprints.vercel deploy
On your machine, run vercel link and vercel env pull, or set AI_GATEWAY_API_KEY, so model calls can authenticate. The same code keeps sessions in files under $TMPDIR/libfx/sessions. To keep them in another World, such as Postgres, see Self-host on another World.
session.steer(text) steers the running turn: it cuts a streaming reply short, or waits for a running tool, and the turn continues with it. With no turn running, the steer starts one. Any server can steer a session:
import { agent } from '@/lib/agent';
import { getUser, ownsSession } from '@/lib/auth'; // your own auth and session store
export async function POST(request: Request) {
const { sessionId, text } = await request.json();
const user = await getUser(request);
if (!user || !(await ownsSession(user, sessionId))) return new Response('Not found', { status: 404 });
await agent.session(sessionId).steer(text);
return new Response(null, { status: 204 });
}import { agent } from '../../lib/agent.js';
import { getUser, ownsSession } from '../../lib/auth.js'; // your own auth and session store
export async function POST(request: Request) {
const { sessionId, text } = await request.json();
const user = await getUser(request);
if (!user || !(await ownsSession(user, sessionId))) return new Response('Not found', { status: 404 });
await agent.session(sessionId).steer(text);
return new Response(null, { status: 204 });
}If the response ends before the turn does, the turn keeps running. Reconnect with GET /api/chat?sessionId=...&cursor=..., with the last cursor you read. The GET handler in the chat route streams the session from just after that cursor and stays open for its next turns, so stop reading at the turn's turn_end.
An app can make several agents, each with its own chat route. Give each one a name:
import { createFxAgent } from 'libfx';
import { tools } from './tools'; // your tools, each with idempotent set
export const support = createFxAgent({ name: 'support', model: 'anthropic/claude-sonnet-5.5', tools });
export const coding = createFxAgent({ name: 'coding', model: 'anthropic/claude-opus-5.5', tools });A session's turns run only on an agent with the name of the agent that created it. Keep each name stable, because sessions that already started carry it. An agent without a name is named default.
One queue route serves every agent. Import every module that creates one, and mount libfx's wakeHandler():
import { wakeHandler } from 'libfx';
import '../lib/agents.js'; // every module that creates an agent
export const POST = wakeHandler();On Next.js, withDurableHarness imports every module that exports an agent, so you don't write this route.
An fx agent has only the tools you give it. To give it fx's own read_file, glob_files, grep_files, edit_file, write_file, and shell, add fxTools() from durable-harness/fx to its tools, with a workspace for them. They never run on your function's disk, in its processes, or with its environment, which other sessions on the instance share:
npm i durable-harness just-bashimport { createFxAgent } from 'libfx';
import { virtual } from 'durable-harness';
import { fxTools } from 'durable-harness/fx';
import { tools } from './tools'; // your tools, each with idempotent set
export const agent = createFxAgent({ model: 'anthropic/claude-sonnet-5.5', tools: [...fxTools(virtual()), ...tools] });The tools run on a virtual workspace at /workspace in your function, where shell is just-bash, whose commands can't reach the function's files, processes, or environment. Each call that changes files records the change in the session's log, so the files follow the session to any function, and a call that a crash interrupts runs again without changing anything twice.
npm i durable-harness @vercel/sandboximport { createFxAgent } from 'libfx';
import { sandbox } from 'durable-harness';
import { fxTools } from 'durable-harness/fx';
import { tools } from './tools'; // your tools, each with idempotent set
export const agent = createFxAgent({ model: 'anthropic/claude-sonnet-5.5', tools: [...fxTools(sandbox()), ...tools] });The tools run in the session's own Edgenivo Sandbox, with its files on a Drive at /workspace that lasts across turns. The agent stays in your function, and the Sandbox starts at the first tool call. What a call wrote stays on the Drive even if a crash interrupts it, so libfx never runs an interrupted call again, and tells the model it may have partly run. On your machine, run vercel link and vercel env pull first: the Sandbox starts on Edgenivo, under the linked project.
Every session can start a Sandbox, and the chat route is your own, so check who's calling, as the chat route above does, and cap each session's Sandbox time with sandbox({ budget: { vmSeconds } }). A Sandbox runs for at most 4 hours a session by default. See Security.
If you pass the agent a World of your own, as Self-host on another World shows, pass it to fxTools() too, as in fxTools(virtual(), { world: sessions }). For tools you write, see Tools, files, and documents and Security.
After a crash, Edgenivo Queues redelivers the turn, and a new function continues it from its last saved step. An idempotent: true tool that was running runs again. An idempotent: false tool returns an error that tells the model it may have partly run, so the model can check before it calls it again.
Turns aren't limited to one function. Shortly before the function's deadline, libfx saves the turn and continues it in a new invocation.
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, such as Postgres on your own servers, install it and 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'; // your tools, each with idempotent set
// 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. For the Postgres tables and connection string, see Self-hosting durable agent harnesses.
To see each session in Agent Runs, pass hooks: agentRunsHooks() from durable-harness/fx to createFxAgent, and set up Agent Runs as Observability shows.
- While a turn can run for much longer, a single model request or tool call can't run longer than the maximum duration of the function running it, the chat route or the queue route.
- Renaming an agent, or naming one that had no
name, leaves its sessions with no agent to run them. A function that gets a turn for an agent it doesn't create refuses it, and the queue delivers it again. - On your machine or on the Postgres World, a server that's killed or restarted mid-turn leaves the turn open until the session's next message, or until you call
session.resume(). The next message's response streams only its own turn: read the resumed one withGETfrom your last cursor.
Was this helpful?