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 prompt <sid> <text…> send + block until the reply
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 rm <sid> delete a session
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.
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.
API traps (opencode 1.15.10, found the hard way)
- The
/api/*v2 surface is advertised in the OpenAPI doc but not implemented — every call returnsServiceUnavailableError: not available yet. Use the v1 endpoints (POST /session/{id}/message,/prompt_async,GET /session/{id}/message). - 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.