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:

  1. Prompts always say “confirm by name only — never print the value.”
  2. 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.
  3. The CLI’s read/prompt print user + assistant text only — tool-call outputs (which may contain file contents) are dropped before they reach the caller. Inspecting changes happens via diff, 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). always is 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 returns ServiceUnavailableError: 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 phantom idle for 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 idle in status. “Idle” is not “done” — always check for pending permissions before treating a session as finished.
  • GET /session/{id}/diff is git-snapshot-based and returns nothing in non-git directories.
  • GET /config returns 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.