How I let one AI agent (a cloud-hosted Claude Code session) spawn and drive another (opencode, running on local hardware against a self-hosted vLLM) through opencode’s HTTP API. This is the public, genericized version of an internal runbook — paths and hostnames are illustrative.
The core insight: sessions are API objects, not processes
opencode serve runs as a persistent service (a systemd user unit on a dev box, bound to loopback, reverse-proxied behind SSO for human use). Its HTTP API multiplexes sessions: POST /session?directory=<path> creates one scoped to any project directory on the box. “Spawning an opencode session” therefore needs no tmux, no systemd-run, no second server — it’s one HTTP call against a server that’s already up.
A thin CLI (~200 lines of bash + python, ocode) wraps the API into zbus-style verbs:
ocode new --dir <path> [--title <t>] spawn a session, prints its id
ocode drive <sid> [--timeout S] <text…> send + run to completion: auto-approves the
session's own safe file-edit gates and halts
(non-zero exit) on anything else. Use this for
any task that touches more than one gate in a
row — a single prompt/wait + one approval can
strand you mid-sequence (see below).
ocode prompt <sid> <text…> send + block until the reply (does NOT
auto-approve gates)
ocode send <sid> <text…> send async
ocode wait <sid> [--timeout S] block until idle (exit 3 = blocked on a permission)
ocode read <sid> [n] [--full] recent messages, text parts only
ocode sessions / status <sid> list / one-word state
ocode perms / allow <pid> once|always|reject
ocode answer <qid> <label>[,…] | --reject
ocode diff <sid> files the session changed (git dirs only)
ocode scrub <sid> / done <sid> shred+remove the session's scratch dir,
session stays alive
ocode rm <sid> scrub the scratch dir, then delete the session
ocode busy is any session running anywhere on the
server right now? (a bare status call only
sees sessions in the default directory, so
it can read "free" while another is streaming)
Off-box callers reach the loopback API by wrapping curl in ssh — the API itself never gets exposed past localhost, because the control plane includes endpoints that return resolved provider credentials.
Why drive opencode at all: the secrets lane
The interesting use case is delegation across a trust boundary. A cloud-inference agent must never read secret material — anything it reads transits the model provider’s servers. A locally-inferring agent (opencode on self-hosted vLLM) has no such constraint: its tokens never leave the LAN.
So the division of labor is: the cloud agent stages everything deterministic (configs, scripts, briefs) and then hands opencode the narrow, mechanical tasks that require touching secret bytes — populating env files, copying a key from one credential store to another, running a deploy that needs to read credentials. The cloud agent orchestrates via ocode; the local agent does the touching.
Three layers keep secrets from leaking back through the transcript:
- Prompts always say “confirm by name only — never print the value.”
- The local agent’s global instructions forbid quoting secret values in replies, summaries, or commit messages — its reply text becomes the cloud agent’s context, so the reply channel is itself an exfiltration path.
- The CLI’s
read/promptprint user + assistant text only — tool-call outputs (which may contain file contents) are dropped before they reach the caller. Inspecting changes happens viadiff, not transcripts. - The cloud agent never creates or edits the secret file itself. Most cloud-agent harnesses re-inject a file’s contents into context when it changes out-of-band — so if the cloud agent pre-creates a stub config and the local agent then fills in secrets, that write can surface the now-secret contents straight back into cloud context, undoing layers 1–3. Delete any stub and have the local agent author the whole file; verify only by key name, never by reading values.
- Never let the local agent harvest a secret from transcripts, logs, shell history, or backups. A missing or unknown credential gets re-minted through its owning system and vaulted fresh, or the task stops and asks — it never goes digging through old session data for a copy that already exists somewhere.
Permission gates: who answers what
opencode pauses on configured permission gates (secret-file reads, destructive bash, outward actions like git push), and the pauses surface through the API (GET /permission → reply once|always|reject). Policy when an agent is driving:
- Self-approve with
once: gates that are the point of the delegation (the env-file read it was asked to do).alwaysis forbidden — it would durably widen the local agent’s standing permissions beyond the task. - Relay to the human, never self-approve: destructive bash, force-pushes, container lifecycle. Those gates exist for the operator; an agent answering them defeats them.
ocode drive encodes exactly this split: it auto-approves the self-approvable class (file read/edit) and halts on everything else, printing whatever needs a human.
API traps (found the hard way; re-check against your running version — this moves)
- A
/api/*v2 surface exists alongside the v1 one. On opencode 1.15.10 every v2 call returnedServiceUnavailableError: not available yet; by 1.18.3 its GET routes return real data. The CLI wrapper described above still only talks to the v1 endpoints (POST /session/{id}/message,/prompt_async,GET /session/{id}/message) and that’s the supported way to drive a session either way — don’t hand-roll v2 calls without re-verifying its write paths first. - The working directory is resolved per-request from
?directory=, not from the session record. A prompt sent without it silently runs in the server’s default project dir ($HOME). This also applies to/permission,/question, and/session/status— a bare status call reports a phantomidlefor any session outside the default directory. Pass the directory on every call; sweep all known session directories when listing pending permissions. - A session parked on a permission/question reads
idlein status. “Idle” is not “done” — always check for pending permissions before treating a session as finished. GET /session/{id}/diffis git-snapshot-based and returns nothing in non-git directories.GET /configreturns provider blocks with API keys env-substituted to cleartext — never expose the control-plane API beyond loopback.- The one flow that can’t be driven headless is MCP OAuth (
opencode mcp auth) — it needs a browser plus a loopback callback, which means an ssh port-forward on a headless box.
What this enables
In practice: a cloud agent shipped a new service end-to-end — wrote the code, the docs, and the configs — then drove an opencode session through populating four env keys, two deploy cycles, and live smoke tests, approving the env-read gates it had itself requested and never once seeing a secret byte. The human’s role collapsed to two yes/no approvals and a visual check.