This server exposes opencode through the Anthropic Managed Agents API spec. Any client that already speaks that spec — including the LiteLLM Agent Platform (LAP) SDK's claude_managed_agents runtime — drives it with zero code changes: just point api_base + api_key at this server. Under the hood the server translates Anthropic Managed Agents calls into opencode: it spawns opencode serve, provisions per-agent config, proxies prompts, and translates opencode's SSE events back into Anthropic event shapes.
In other words: to the SDK this looks exactly like Anthropic's Managed Agents service. opencode is an implementation detail nobody on the client side ever sees.
The whole point is that the front speaks the Anthropic Managed Agents spec, so a client SDK needs no opencode-specific code — and the SDK's mature, already-shipped claude_managed_agents path is reused verbatim, no second runtime, no adapter, no fork. Every method the SDK already has maps cleanly onto opencode:
create_agent→ durable agent record + a provisioned.opencode/agent/<id>.md(system prompt, model, permissions).create_environment→ a named workspace config (optionally a gitrepository/ref).create_session→ boots/attaches the child opencode for that agent and opens an opencode session.events().send(...)→ forwards auser.messageto opencode as a prompt.events().stream(...)→ opencode's SSE is translated into Anthropic event types (agent.message,agent.thinking,agent.tool_use,agent.tool_result,session.status_*,session.error).
Because the contract is identical, you change only api_base/api_key and the existing managed-agents code path drives opencode.
- Anthropic-spec front — an Express app that implements the Anthropic Managed Agents endpoints (
POST /v1/agents,POST /v1/environments,POST /v1/sessions,POST /v1/sessions/:id/events,GET /v1/sessions/:id/events/stream, …) and honorsx-api-key/anthropic-version/anthropic-beta: managed-agents-2026-04-01. - Durable agent store — agents, environments, and session→agent bindings persisted in SQLite (
better-sqlite3, WAL) atDB_PATH, so agents survive restarts and keep stableagt_…/env_…/ session ids. - Child opencode provisioned per session + SSE translation — the server boots one
opencode servechild, provisions per-agent config (an agent.mdfile plusopencode.jsonMCP entries) per session, proxies prompts to it, and rewrites opencode's event stream into Anthropic event frames.
docker build -t opencode-anthropic-server .
docker run -p 8080:8080 -e ANTHROPIC_API_KEY=sk-ant-... opencode-anthropic-serverThe opencode CLI must be installed and on PATH:
npm i -g opencode-aiThen:
npm install && ANTHROPIC_API_KEY=... npm startModel provider key required. To actually answer prompts, the child opencode needs a model provider key in the server's environment (e.g.
ANTHROPIC_API_KEYforanthropic/*models). Without it, agents/environments/sessions create fine, but prompts won't produce assistant output. This key is the server's — it is not thex-api-keyyour clients send.
The LiteLLM Agent Platform SDK already ships a claude_managed_agents runtime that speaks the Anthropic Managed Agents spec. Point it at this server and it drives opencode without a single new line of integration code:
let lap = Lap::new(LapConfig {
anthropic_api_key: Some("any-key".into()),
anthropic_base_url: "https://<this-server>".into(),
..Default::default()
});
// runtime: claude_managed_agents
let agent = lap.beta().agents().create(/* name, model, system */).await?;
let session = lap.beta().sessions().create(/* agent, environment */).await?;
lap.beta().sessions().events().send(&session.id, /* user.message */).await?;
let mut stream = lap.beta().sessions().events().stream(&session.id).await?;opencode, driven through the Anthropic Managed Agents SDK path, by changing only
api_base/api_key.
Full end-to-end example using the LiteLLM Agent Platform Rust SDK (litellm_rust).
The only server-specific config is anthropic_base_url + anthropic_api_key;
everything else is the SDK's normal claude_managed_agents flow.
use litellm_rust::sdk::agents::{
AgentModel, AgentRuntime, CreateAgentParams, CreateEnvironmentParams,
CreateSessionParams, Lap, LapConfig, SendEventsParams,
};
use futures_util::StreamExt;
use serde_json::json;
// 1. Point the SDK at this server (key is accepted loosely).
let lap = Lap::new(LapConfig {
anthropic_api_key: Some("any-key".into()),
anthropic_base_url: "https://<this-server>".into(), // e.g. http://localhost:8080
..LapConfig::default()
});
// 2. Create an agent. Use the gateway provider id in the model, e.g. "litellm/<model>".
let agent = lap.beta().agents().create(CreateAgentParams {
lap_agent_runtime: AgentRuntime::ClaudeManagedAgents,
lap_provider_options: None,
name: "Demo".into(),
model: AgentModel::from("litellm/claude-sonnet-4-5"),
system: "You are a terse assistant.".into(),
description: None,
tools: Vec::new(),
mcp_servers: Vec::new(), // e.g. [{ "name": "deepwiki", "url": "https://mcp.deepwiki.com/mcp" }]
env_vars: None,
workspace: None,
metadata: None,
}).await?;
// 3. Environment (a named workspace; config is optional).
let env = lap.beta().environments().create(CreateEnvironmentParams {
lap_agent_runtime: AgentRuntime::ClaudeManagedAgents,
name: "demo-env".into(),
config: json!({}),
description: None,
scope: None,
}).await?;
// 4. Session bound to the agent (this provisions opencode for it).
let session = lap.beta().sessions().create(CreateSessionParams {
agent: agent.id.clone(),
environment_id: env.id.clone(),
title: "demo session".into(),
lap_agent_runtime: Some(AgentRuntime::ClaudeManagedAgents),
metadata: None,
vault_ids: None,
resources: None,
}).await?;
// 5. Send a user message.
lap.beta().sessions().events().send(&session.id, SendEventsParams {
events: vec![json!({
"type": "user.message",
"content": [{ "type": "text", "text": "Name the three primary colors." }]
})],
}).await?;
// 6. Stream the reply (Anthropic event types: agent.message, session.status_*, agent.tool_use, ...).
let mut stream = lap.beta().sessions().events().stream(&session.id).await?;
while let Some(Ok(event)) = stream.next().await {
if event.event_type == "agent.message" {
if let Some(blocks) = event.data.get("content").and_then(|c| c.as_array()) {
for b in blocks {
if let Some(t) = b.get("text").and_then(|t| t.as_str()) { print!("{t}"); }
}
}
}
if event.event_type == "session.status_idle" { break; }
}A runnable version lives at tests/opencode_anthropic_server_live.rs in the
parent repo (a #[ignore]d live test). With the server running:
OPENCODE_ANTHROPIC_BASE=http://localhost:8080 \
OPENCODE_ANTHROPIC_MODEL=litellm/claude-sonnet-4-5 \
cargo test --test opencode_anthropic_server_live -- --ignored --nocapture
# -> [live] >>> ASSISTANT SAID: Red, yellow, blue.LAP itself needs no opencode-specific code — register this server's URL/key
as a claude_managed_agents runtime credential and the existing agent/session
flow drives it.
Base URL defaults to http://localhost:8080. All /v1/* calls honor x-api-key, anthropic-version, and anthropic-beta: managed-agents-2026-04-01 (the API key is accepted loosely for the demo).
Body: {name, model, system, description?, tools?, mcp_servers?, permissions?, metadata?}. model is a string ("anthropic/claude-sonnet-4-5") or {id}. Returns a durable agent {id:"agt_...", type:"agent", name, model:{id}, system, version, created_at, ...}.
curl -s -X POST "$BASE/v1/agents" \
-H "content-type: application/json" \
-H "x-api-key: any-key" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: managed-agents-2026-04-01" \
-d '{
"name": "Docs Helper",
"model": "anthropic/claude-sonnet-4-5",
"system": "You are a terse documentation assistant.",
"permissions": { "bash": "ask", "edit": "allow" },
"mcp_servers": []
}'GET /v1/agents lists agents; GET /v1/agents/:id fetches one.
Body: {name, config?, description?, scope?} → {id:"env_...", type:"environment"}. config may carry a workspace repository/ref.
curl -s -X POST "$BASE/v1/environments" \
-H "content-type: application/json" \
-H "x-api-key: any-key" \
-d '{
"name": "docs-env",
"config": { "repository": "https://github.com/acme/docs", "ref": "main" }
}'Body: {agent:"agt_...", environment_id?, title?, metadata?} → {id, type:"session", agent, environment_id, status:"running"}. Provisions opencode for the agent.
curl -s -X POST "$BASE/v1/sessions" \
-H "content-type: application/json" \
-H "x-api-key: any-key" \
-d '{ "agent": "agt_123", "environment_id": "env_123", "title": "hello" }'Body: {events:[{type:"user.message", content:"..." | [{type:"text",text:"..."}]}]}. Forwards the prompt to opencode and returns 202 Accepted; the agent's reply arrives on the SSE stream.
curl -s -X POST "$BASE/v1/sessions/ses_123/events" \
-H "content-type: application/json" \
-H "x-api-key: any-key" \
-d '{ "events": [ { "type": "user.message", "content": [ { "type": "text", "text": "Say hello in 3 words." } ] } ] }'Server-Sent Events. Frames are event: <type>\ndata: <json>\n\n. Anthropic event types emitted:
| Event | Data |
|---|---|
agent.message |
{content:[{type:"text",text}], model} |
agent.thinking |
reasoning delta |
agent.tool_use |
tool call |
agent.tool_result |
tool result |
session.status_running |
session became active |
session.status_idle |
turn finished |
session.error |
error payload |
curl -sN "$BASE/v1/sessions/ses_123/events/stream" -H "x-api-key: any-key"GET /v1/sessions/:id/events returns the buffered list as {data:[...]}.
curl -s "$BASE/health" # {"ok":true,"opencode":true}Create an agent → environment → session, open the SSE stream in the background, POST a user.message, and watch agent.message followed by session.status_idle.
set -euo pipefail
BASE="${BASE:-http://localhost:8080}"
H=(-H "content-type: application/json" -H "x-api-key: any-key")
# 1. create an agent
aid=$(curl -s "${H[@]}" -X POST "$BASE/v1/agents" \
-d '{"name":"E2E","model":"anthropic/claude-sonnet-4-5","system":"You are terse."}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
# 2. create an environment
eid=$(curl -s "${H[@]}" -X POST "$BASE/v1/environments" \
-d '{"name":"e2e-env","config":{}}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
# 3. create a session bound to the agent + environment
sid=$(curl -s "${H[@]}" -X POST "$BASE/v1/sessions" \
-d "{\"agent\":\"$aid\",\"environment_id\":\"$eid\",\"title\":\"e2e\"}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
# 4. open the SSE stream in the background
curl -sN "$BASE/v1/sessions/$sid/events/stream" -H "x-api-key: any-key" &
sse_pid=$!
sleep 1
# 5. send a user.message
curl -s "${H[@]}" -X POST "$BASE/v1/sessions/$sid/events" \
-d '{"events":[{"type":"user.message","content":[{"type":"text","text":"Say hello in 3 words."}]}]}'
# 6. watch agent.message ... then session.status_idle, then stop
sleep 8
kill "$sse_pid" 2>/dev/null || trueYou should see session.status_running, one or more agent.message frames, and finally session.status_idle.
opencode only scans custom agents and per-project config when the workspace is a git project, and it loads them at boot — there is no hot-reload. So this server:
git inits theWORKDIRon startup.- On
POST/PATCH /v1/agents, writes the agent's.opencode/agent/<id>.md+opencode.jsonmcp, then reboots the childopencodeso it loads. - On prompt, passes
agent:<id>so opencode applies that agent's permissions and MCP servers.
Reboot is fast (~2s) but clears opencode's in-memory sessions — create/update agents before running their sessions.
| Field | Type | Maps to | Applied? |
|---|---|---|---|
model |
string or {id} |
per-prompt {providerID, modelID} |
✅ yes |
mcp_servers |
array | opencode.json mcp |
✅ yes — tools are callable (verified with DeepWiki) |
permissions |
object | agent frontmatter | ✅ yes — bash / edit / <mcp>* → allow | deny | ask |
system |
string | .opencode/agent/<id>.md body |
|
workspace (via environment config) |
object | — | 🚧 reserved — not yet wired; accepted and stored but no repo clone/checkout happens yet |
opencode is a coding harness: it injects its own large (~19.7k-token)
agent system prompt, which dominates. Your system string is appended but
does not reliably override behavior (e.g. "reply only BANANA" is ignored).
Use it to nudge tone/role; do not rely on it for strict output control. If
you need a faithful system prompt, call the model directly (or use a
non-opencode harness) — opencode is the right backend for tool / MCP / coding
workflows, where it shines.
| Var | Default | Purpose |
|---|---|---|
PORT |
8080 |
port the Anthropic-spec front listens on |
OPENCODE_PORT |
4096 |
port the child opencode serve binds |
WORKDIR |
/tmp/opencode-workspace |
workspace where per-agent config is provisioned |
DB_PATH |
/data/agents.db |
SQLite file for agents/environments/sessions |
ANTHROPIC_API_KEY |
— | model provider key opencode uses to answer prompts (native Anthropic) |
LITELLM_BASE_URL |
— | route models through a LiteLLM gateway instead (e.g. https://your-gw/v1) |
LITELLM_API_KEY |
— | LiteLLM gateway key |
LITELLM_MODELS |
claude-sonnet-4-5,gpt-5.5 |
gateway models to register |
OPENSANDBOX_API_URL |
— | OpenSandbox controller URL → routes the agent's command/file execution into a sandbox (auto-enables sandboxed execution) |
OPENSANDBOX_IMAGE |
default |
sandbox image (execution-only, so the base image is fine) |
OPENSANDBOX_API_KEY |
— | controller API key; omit for in-cluster RBAC |
SANDBOX_PROVIDER |
— | explicit provider select (opensandbox); else auto-detect from OPENSANDBOX_API_URL |
Set OPENSANDBOX_API_URL (or SANDBOX_PROVIDER=opensandbox) and the server runs
the agent's commands/file ops in an OpenSandbox sandbox instead of on this
host: it denies opencode's native bash/edit and wires a sandbox-exec MCP
(sandbox_exec / sandbox_read_file / sandbox_write_file) backed by raw HTTP
calls to the OpenSandbox controller + execd (no SDK dependency). opencode itself
stays here; only execution is isolated.
When LITELLM_BASE_URL + LITELLM_API_KEY are set, the server configures an
opencode provider litellm (via opencode's native Anthropic adapter pointed at
{LITELLM_BASE_URL}/messages). Address models as litellm/<model>, e.g. an
agent with "model": "litellm/claude-sonnet-4-5".
Stops the in-flight generation (proxies opencode's session abort):
curl -X POST $BASE/v1/sessions/$SID/abort -H "x-api-key: k" # -> {"aborted":true}The event stream stops emitting agent.message and settles on
session.status_idle.
Driven through the LAP SDK (claude_managed_agents, only api_base/api_key
pointed here) against a LiteLLM-gateway-backed server:
- query → response —
Name the three primary colors→ streamedagent.messagedeltas →Red, yellow, blue.→session.status_idle. - query → interrupt — a 500-word essay request streamed
(
# The Rise, Glory, and Fall of the Roman Empire …), thenPOST …/abort→{"aborted":true}and zero further tokens; the turn ended. - MCP tool use — an agent created with
mcp_servers:[{name:"deepwiki",url:…}](+permissions:{"deepwiki*":"allow"}) → asked to look up a repo → emittedagent.tool_use(deepwiki_read_wiki_structure) and returned real wiki sections forfacebook/react/vercel/next.js.
Reproduce the SDK path with:
cargo test --test opencode_anthropic_server_live -- --ignored --nocapture
(set OPENCODE_ANTHROPIC_BASE + OPENCODE_ANTHROPIC_MODEL).
Deploy as a Docker web service:
- New → Web Service → point at this repo; Render builds the
Dockerfile. - Add a mounted disk and set its mount path to the directory of
DB_PATH(e.g. mount at/var/dataand setDB_PATH=/var/data/agents.db) so the SQLite agent store survives deploys/restarts. - Set the model provider key env var (
ANTHROPIC_API_KEY=...) so the child opencode can answer prompts. - Health check path:
/health(returns{ok:true, opencode:bool}).
PORT is provided by Render; OPENCODE_PORT and WORKDIR can be left at defaults.
This is a standalone server the LAP / lite-harness SDK talks to via the Anthropic Managed Agents contract — the same claude_managed_agents runtime, with only api_base/api_key changed. opencode is purely an implementation detail behind that contract.