OpenCode engine
Run the agent's turns on OpenCode 2 sessions behind the library's routes, stream protocol, approvals, questions and queue — with the app's tools served to OpenCode over an internal MCP endpoint.
An engine runs a turn instead of the library's own loop. Everything around the turn stays the
library's: the /agent routes and the stream protocol (so @adonis-agora/agent/react works
unchanged), threads and the store, the sink, approvals and questions through AgentService, the
message queue and the quota gate. The engine owns the model calls, the tools and the context it keeps.
@adonis-agora/agent/opencode is the engine that ships: it runs turns on OpenCode 2
sessions. The OpenCode client is structural, so the subpath has no runtime dependency on
@opencode/client; you hand it a client (OpenCode.make({ baseUrl, headers }) from
@opencode/client 2.x satisfies it).
The host
The host is the one thing you write: which OpenCode server a turn runs on, and how a thread's session is created.
import { OpenCode } from '@opencode/client'
import type { OpenCodeHost, OpenCodeTurnContext } from '@adonis-agora/agent/opencode'
import type { Actor } from '@adonis-agora/agent'
export class AppOpenCodeHost implements OpenCodeHost {
#client = OpenCode.make({
baseUrl: 'http://opencode.internal:4096',
headers: { authorization: `Basic ${btoa(`opencode:${process.env.OPENCODE_PASSWORD}`)}` },
})
/** Which server runs this actor's turns. `bootId` changes when it restarts and loses sessions. */
async server(actor: Actor) {
return { client: this.#client, key: 'main', bootId: await this.bootIdOfTheSandbox() }
}
/** How a thread's session is created (only when it has none on this server). */
async session({ input }: OpenCodeTurnContext) {
return {
location: { directory: `/work/${input.actor.id}` },
model: { providerID: 'anthropic', id: 'claude-sonnet-4-5' },
permissions: [
{ action: '*', resource: '*', effect: 'deny' },
{ action: 'question', resource: '*', effect: 'allow' }, // → an elicitation form
{ action: 'webfetch', resource: '*', effect: 'ask' }, // → an approval card
],
}
}
/** Extra instructions entries, refreshed every turn. */
async instructions() {
return { 'app.profile': 'The user writes short emails.' }
}
}import { defineConfig, stores } from '@adonis-agora/agent'
import { openCode } from '@adonis-agora/agent/opencode'
import { AppOpenCodeHost } from '#agent/opencode_host'
import env from '#start/env'
export default defineConfig({
// No `model`: the engine runs the turns.
engine: openCode({
host: AppOpenCodeHost, // a class is built by the container (`@inject()` works); an instance works too
// Where the OpenCode server reaches this app's MCP endpoint — see "The tools URL" below.
tools: { url: env.get('OPENCODE_TOOLS_URL') },
}),
store: 'lucid',
stores: { lucid: stores.lucid() },
defaultAgent: { systemPrompt: 'You are the support assistant.' },
})engine also takes a lazy factory (engine: async () => (await import('…')).openCode({ … })).
With an engine model is optional, and durable: true — which names the loop's own durable runner —
is refused at boot unless the engine is durable itself (openCodeDurable(), below).
What OpenCode's events become
| OpenCode | The library's stream and store |
|---|---|
session.text.delta / session.reasoning.delta | text / reasoning |
a step (session.step.ended) | step-start … step-finish with usage and cost; a chat usage row per step (see Cost and usage) |
| the title OpenCode generates, a compaction | a title / summary usage row (see Cost and usage) |
tools (session.tool.*) | tool-input-* / tool-output*; code-mode inner calls nested by parentId |
permission.asked | an action call + approval-requested, recorded pending_approval; approve / reject → permission.reply |
form.created (the question tool) | elicitation; answer / skip → session.form.reply / cancel |
session.renamed | title |
session.execution.* | the run settles: queue handoff, cancelled, or an event: error |
cancel (POST <path>/chat/:runId/cancel) | session.interrupt |
One session per thread, kept by an OpenCodeSessionStore (in memory by default). A session is
recreated when the server's bootId changes, and told the conversation so far.
No streaming tool input
On this engine a tool call's arguments arrive whole, so nothing previews while the model writes
them: ToolHandler.previewInput never runs, and a ui__render tree (or any streaming: 'partial'
component) appears once, validated, when the call completes. On aiSdkModel the same tools stream
progressively (Generative UI).
The cause is upstream. OpenCode's event schema has a live-only session.tool.input.delta event, but
the server (2.0.18 and 2.0.19) does not publish it: it buffers the model's tool-input deltas and
publishes only session.tool.input.started (the tool's name), then session.tool.input.ended (the
whole raw JSON) and session.tool.called (the parsed input), back to back once the arguments are
complete. The 1.x line does not stream tool input either. Previews need OpenCode to publish
session.tool.input.delta { id, delta } as the input buffer fills, the way it publishes
session.text.delta; the engine would then forward each delta as a tool-input-delta frame and run
the tool's previewInput over it.
This covers MCP tools too, with a second obstacle. OpenCode runs MCP tools in code mode by default:
the model calls one execute tool and writes JavaScript that calls tools.<server>.<tool>(…), so a
tool on your own MCP server (a ui__show_* component tool, say) is not a tool call of its own while
the model writes it, and the MCP server only ever receives the complete arguments in tools/call.
Even with the deltas published, an MCP tool would preview only if it were registered with
codemode: false, which offers it to the model directly as <server>_<tool>.
Cost and usage
Every model call OpenCode makes for a turn — each step, the title it gives the session, a
compaction — goes to the same token-usage ledger the loop writes (agent_token_usage, with
cost_usd and cost_source), so the quota, the governance views and GET <path>/quota see an
OpenCode turn like any other.
- The title and compactions. OpenCode records those calls on the session without streaming
them. The turn reads what the session had spent (
session.get) before it prompts, and when the execution ends records what the session spent since that its steps did not report, as atitlerow (summarywhen a compaction ran). Asession.usage.recordedevent, from an OpenCode that streams it, is recorded as it arrives and not counted twice. - Tokens. OpenCode reports the uncached input in
tokens.inputand the cache beside it; the row counts the whole input side ininputTokens, withcacheReadTokens/cacheWriteTokensas subsets. The row's model is the one OpenCode names for the step (<providerID>/<model id>). - Cost. A call OpenCode priced (
cost > 0, off its model catalog) is recorded at that figure,cost_source: 'provider'. A call it priced at 0 — no price for the model in its catalog — is estimated from the library's price,cost_source: 'estimate': thepricingStorerow, else (seeded into the store once, as at boot)priceCatalog.prices, the built-in GovCloud Bedrock table (priceCatalog.region/AWS_REGION, or aus-gov.id) and models.dev. A model the library cannot price keeps OpenCode's 0. openCode({ cost: 'estimate' })prefers the library's price whenever it has one — for models OpenCode would price at a list price that is not yours (models.dev lists commercial Bedrock prices, so Bedrock in GovCloud is priced too low unless OpenCode's config declares the model'scost).- The run's total (
OpenCodeRunResult.usage, the run row's tokens, cost and step count, and the usage on the turn's last message) is added up from the turn's own calls, not read back from the store: each milestone carries what was spent since the last one, and a durable run journals it, so a run resumed in another process still reports the whole run, cache tokens included.
The library's seams under OpenCode
| Seam | In the session |
|---|---|
the agent's systemPrompt and its persona | instructions entry agora.system, every turn |
approvalPolicy | consulted on every permission.asked: not required → answered at once; otherwise its approver and ttlMs (expiry → rejected) |
| remembered approvals | answered without asking |
the app's tools (tools: { url }) | served over the engine's MCP endpoint; reads allowed, actions asked, tools outside the agent's allow-list denied |
skills | .opencode/skills/<name>/SKILL.md in the session's directory, allowed for OpenCode's skill tool |
memory | instructions entry agora.memory; with tools and a provider that writes, a remember tool (one fact, at the actor's own scope) |
ctx.emitUi in a tool | the component is a ui frame on the turn's stream, persisted on its message |
| regenerate | the thread is rewound and the session reverted (session.revert) |
What an engine does not get: everything that lives inside the loop — input/output processors,
outputSchema, maxSteps, historyWindow, inject-mode retrieval, delegation (delegatesTo), the
loop's quota check per step (the send gate still applies). Under OpenCode those are OpenCode's.
The app's tools over MCP
With tools set, the engine mounts POST <path>/opencode/mcp and registers it in each session
(mcp.add, as the agora server) with a bearer token. It is not the config/mcp.ts surface: it
exists only to serve OpenCode turns, and it is built to stay safe if the token leaks out of the
sandbox.
The tools URL
tools.url has no default and is not derived from anything: the engine passes it verbatim to
OpenCode (mcp.add with { type: 'remote', url, headers: { Authorization: 'Bearer <token>' } }),
and it is the OpenCode server — a separate process, often in another container or sandbox — that
calls it back. So it must be an address of this Adonis app as seen from the OpenCode server,
ending in <path>/opencode/mcp (path is the agent's route prefix, agent by default). There is
no special hostname: app.internal in older examples was only a placeholder. Keep it in an env
variable (OPENCODE_TOOLS_URL: Env.schema.string() in start/env.ts) so each deployment sets its own.
| Where OpenCode runs | OPENCODE_TOOLS_URL |
|---|---|
| Same machine as the app | http://127.0.0.1:3333/agent/opencode/mcp |
| Docker Compose | the app's service name: http://web:3333/agent/opencode/mcp |
| Kubernetes | the app's Service DNS: http://web.my-namespace.svc.cluster.local:3333/agent/opencode/mcp |
| Anywhere else | the app's public URL works (https://app.example.com/agent/opencode/mcp) |
Prefer an internal network address: the endpoint only ever serves OpenCode, and a public URL puts it
on the internet (it stays guarded by the token, below). If several app processes sit behind that
address, give them all the same tools.secret (or the same APP_KEY), so a token signed by one is
accepted by the others; without either, each process signs with its own random secret and the engine
logs a warning.
What guards the endpoint
- The token names an actor and an OpenCode server, expires (
ttlMs, default 7 days, re-issued half-way), and is HMAC-signed withtools.secret, else a key derived from the app'sAPP_KEY. - A call runs only inside a turn: OpenCode names its session in every call's
_meta; the call must come from a session that is running a turn of the token's actor right now. Anything else is refused. - The agent's allow-list and the roles policy apply to the call, as in the loop.
- An
actiontool runs only against an approval the turn granted — the person approved OpenCode's permission, or the policy / a remembered approval answered it. Each approval runs one call. A caller that skips OpenCode's permission rules cannot run an action unapproved.
Give each actor their own session location.directory: OpenCode registers an MCP server per
directory, so actors sharing one would overwrite each other's token, and calls would be refused.
Durable turns
import { openCodeDurable } from '@adonis-agora/agent/opencode/durable'
export default defineConfig({
engine: openCodeDurable({ host: AppOpenCodeHost, tools: { url: env.get('OPENCODE_TOOLS_URL') } }),
// next to a configured @adonis-agora/durable (its WorkflowEngine is taken from the container)
})Each turn is the agora.agent.opencode.run workflow: begin → prompt → observe → [wait for a person → reply → observe]* → finish, every step checkpointed, a person's decision a durable signal. A turn
parked on an approval survives restarts and is resumed by whichever process receives the decision;
a resumed step first catches up on what OpenCode asked while nobody listened. If OpenCode itself
restarted while the turn waited, a new session is opened with the conversation and the decision.
durable: { start, startError } in the engine's options (or startOptions / startError on the
host) sets each turn's start options — tags, search attributes, a concurrency quota — and maps a
refused start (e.g. to a 429). runId: (input) => … names the runs.
Host hooks and hostContext
AgentService.chat({ …, hostContext }) carries the host's own JSON facts about a send — where it
came from, where its answer goes — to the turn: through the queue (the host_context column, healed
on boot; never on the queue's wire view) and the durable journal. The model and clients never see it.
The host's hooks receive it on input.hostContext:
| Hook | When |
|---|---|
onAsk({ input, runId, sessionId, ask }) | a person was asked (an approval or a form) — post it elsewhere; may run again after a restart |
onUi({ input, runId, component }) | a tool pushed a component |
beforeSettle(result) | the run is over: append components to the answer, or word a failure for the person |
onSettled(result) | the run settled: deliver the answer elsewhere, record spend and telemetry |
Also on the host: reuse (keep the thread's session or open a new one), beforePrompt (update the
session every turn), prepare (a new session, before its first prompt), promptFor (what the session
is prompted with) and files (attachments). Hook errors are logged, never the run's.
engine.turns.pushToSession(sessionId, component) pushes a component into the run a session serves,
for the host's own trusted callers; engine.turns.liveRuns() lists the runs this process follows.
Several processes
Use openCodeDurable(), a cross-process sink (tokenSinks.redis() / tokenSinks.lucid()), a shared
session store — sessions: keyValueOpenCodeSessionStore(redis) — and the same tools.secret (or
APP_KEY) everywhere. The tools endpoint answers from any process: a call is tied to its turn through
OpenCode itself.
Testing against a real OpenCode
The live specs run the engine against opencode serve (2.x, OPENCODE_SERVER_PASSWORD set, with a
key for the model's provider), and are skipped without OPENCODE_LIVE_URL:
OPENCODE_LIVE_URL=http://127.0.0.1:4096 OPENCODE_LIVE_PASSWORD=… \
OPENCODE_LIVE_MODEL=opencode-go/longcat-2.5-preview-free OPENCODE_LIVE_DIR=/tmp/work \
npx vitest run test/opencode/liveThe durable runner
Run each turn as a replay-safe @adonis-agora/durable workflow — memoized LLM/tool steps, HITL approval that suspends on a signal and survives restarts, and delegation as a tracked child workflow. One config flag.
Lucid
Persist threads, messages, tool calls, and token usage to Postgres, MySQL, or SQLite through @adonisjs/lucid — the eleven agent tables, the published migration, and the in-memory twin for tests.