Running OpenCode Durably on Edgenivo
OpenCode v2 keeps every change to a session as an event. durable-harness mirrors those events into a durable log, so any function can rebuild the session and continue it. On Edgenivo, OpenCode runs inside the function that runs the turn. To learn how durability works, see Running Agent Harnesses Durably.
npm i durable-harness @opencode/[email protected] @opencode/[email protected] @opencode/[email protected] @opencode/[email protected]durable-harnesssupports one OpenCode version at a time, so install the one it pins, and keep it exact inpackage.json, with no^. It checks the installed versions before each turn. You don't installeffect: OpenCode's packages bring the version they need.- lib/agent.ts
import { opencode } from 'durable-harness/opencode'; export const agent = opencode({ model: 'anthropic/claude-sonnet-5.5' });The model must be in the model catalog of the OpenCode version
durable-harnesspins. 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 } ], "includeFiles": "node_modules/{web-tree-sitter,tree-sitter-{bash,powershell}}/*.wasm" }, "api/chat.ts": { "includeFiles": "node_modules/{web-tree-sitter,tree-sitter-{bash,powershell}}/*.wasm" } } }The trigger subscribes
api/flow.tsto the queue.includeFilesadds OpenCode's parser files, which Edgenivo's file tracing doesn't find, to the functions that run turns: the queue route and the chat route. These paths are for npm.npx durable-harness vercel-json --writewrites these entries into yourvercel.json, with the paths of your install.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, includes OpenCode's parser files // in the routes that run turns, 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. OpenCode adds it to the running turn at its next step. 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. From server code, such as a webhook, call agent.sessions.send(sessionId, text, 'steer').
If the stream drops, the turn keeps running. 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());If a crash cut off the turn, the turn resumes and the model starts its reply over. Before the new reply, the stream sends a { type: 'restart', text } event: show its text in place of the turn's text so far, then append what streams after it.
An app can make several agents, each with its own chat route. The queue route serves them all. Two agents of the same harness need a name each:
import { opencode } from 'durable-harness/opencode';
export const reviewer = opencode({ name: 'reviewer', model: 'anthropic/claude-opus-5.5' });
export const fixer = opencode({ 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.
OpenCode starts with no tools: the model can call only the tools you pass in tools. To give it OpenCode's own shell, read, write, edit, patch, glob, and grep, pass a workspace. They run OpenCode's own code on the workspace, never on your function's disk or in its processes, which other sessions on the instance share:
npm i just-bashimport { virtual } from 'durable-harness';
import { opencode } from 'durable-harness/opencode';
export const agent = opencode({ model: 'anthropic/claude-sonnet-5.5', workspace: virtual() });The tools run on a virtual workspace in your function, where commands can't reach the function's files, processes, or environment. Each call's file changes commit with its result in the session's log, so the files follow the session to any function, and a call that a crash interrupts changed nothing.
npm i @vercel/sandboximport { sandbox } from 'durable-harness';
import { opencode } from 'durable-harness/opencode';
export const agent = opencode({ model: 'anthropic/claude-sonnet-5.5', workspace: sandbox() });The tools run in the session's own Edgenivo Sandbox, with its files on a Drive at /workspace that lasts across turns. OpenCode itself stays in your function, and the Sandbox starts at the first tool call. A call that changes files or runs a command never runs twice: if a crash interrupts it, the model is told 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. A long command early in a turn can be cut off: see A Edgenivo Sandbox with a Drive.
To give it only some of OpenCode's tools, pass builtins, as in builtins: ['read', 'grep']. Pass tools you write in tools, next to them. See Tools, files, and documents and Security.
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.
On Edgenivo, sessions live in Edgenivo's World, and on your machine in .workflow-data. 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 as opencode({ model, world }), and start it yourself, as Pass a World yourself shows.
For the tables, the server, and recovery at startup, see Self-hosting durable agent harnesses.
- A running OpenCode session uses a lot of memory, so give the functions that run turns enough of it.
- OpenCode needs
effect4.0.0-rc.112, so an app that already depends on another version ofeffectcan't use it yet. - With other versions of OpenCode's packages or
effect, a turn fails with a message that names the versions to install. The session is kept, and the next message works once they're installed.
Was this helpful?