agentruntime is a Go daemon and library for running coding agents behind one consistent API. Today that means agentd creates and tracks sessions, the runtime launches a v2 agentruntime-sidecar, and the sidecar talks to Claude Code or Codex, normalizes their output into a shared event stream, and feeds that stream back through replay buffers and persistent NDJSON logs. The same control plane works locally on the host or inside Docker containers, with Docker adding config materialization and a managed egress proxy.
client
-> POST /sessions on agentd
-> GET /ws/sessions/:id or GET /sessions/:id/logs
agentd
-> session manager + replay buffer + NDJSON log writer
-> runtime: local or docker
runtime
-> launches agentruntime-sidecar
-> local: host process
-> docker: agentruntime-agent container on managed network + squid proxy
agentruntime-sidecar
-> starts Claude Code or Codex
-> speaks /ws using prompt|interrupt|steer|context|mention commands
-> emits normalized events: agent_message|tool_use|tool_result|result|progress|system|error|exit
agent CLI
-> raw CLI output
-> normalized by sidecar
pip install agentruntime-agentdThis installs the pre-built agentd binary for your platform. After installation, agentd is available on your PATH:
agentd --port 8090 --runtime localFor programmatic use:
from agentruntime_agentd import get_binary_path
binary = get_binary_path() # absolute path to the agentd binaryThe default local runtime needs both binaries: agentd and agentruntime-sidecar.
go build -o agentd ./cmd/agentd
go build -o agentruntime-sidecar ./cmd/sidecarRun the daemon with the sidecar binary on PATH:
PATH="$PWD:$PATH" ./agentd --port 8090 --runtime localCreate a prompt-mode session:
SESSION_JSON=$(curl -sS http://127.0.0.1:8090/sessions \
-H 'content-type: application/json' \
-d "{
\"agent\": \"claude\",
\"prompt\": \"Reply with exactly hello from agentruntime.\",
\"work_dir\": \"$PWD\"
}")
printf '%s\n' "$SESSION_JSON" | jq .
SESSION_ID=$(printf '%s' "$SESSION_JSON" | jq -r '.session_id')Stream output over the daemon WebSocket bridge:
websocat "ws://127.0.0.1:8090/ws/sessions/$SESSION_ID?since=0"If you prefer polling instead of WebSockets, read the NDJSON stream incrementally:
curl -sS "http://127.0.0.1:8090/sessions/$SESSION_ID/logs?cursor=0"Or, use the interactive attach command to connect to a running session with terminal I/O:
agentd attach $SESSION_IDThe attach command supports:
--port(default 8090): Daemon port--since N(default 0): Replay offset to start from--no-replay: Skip replay history and only show live output
Stdin modes in attach:
- Regular text lines are sent as stdin
- Lines starting with
/steerare sent as steering commands - Lines starting with
/interruptsend an interrupt signal - Ctrl+C sends interrupt (first time) or detaches (second time)
Build the bundled container images:
./docker/build.shThat script builds:
agentruntime-agent:latestagentruntime-proxy:latest
You can also build them manually:
docker build \
--build-arg HOST_UID="$(id -u)" \
--build-arg HOST_GID="$(id -g)" \
-t agentruntime-agent:latest \
-f docker/Dockerfile.agent \
.
docker build \
-t agentruntime-proxy:latest \
-f docker/Dockerfile.proxy \
dockerRun the daemon in Docker mode:
go build -o agentd ./cmd/agentd
./agentd --port 8090 --runtime dockerWhat happens in Docker mode:
agentdcreates the managed Docker networkagentruntime-agentsif needed.agentdstarts the proxy sidecar containeragentruntime-proxyif needed.- agent containers get
HTTP_PROXY,HTTPS_PROXY, andNO_PROXYinjected automatically. - the runtime starts
agentruntime-agent:latest, which already containsagentruntime-sidecar,claude, andcodex. - Claude and Codex config is materialized into per-session homes under the daemon data directory and mounted into the container.
The default Docker image is agentruntime-agent:latest, so a minimal Docker-backed request is still just:
curl -sS http://127.0.0.1:8090/sessions \
-H 'content-type: application/json' \
-d "{
\"agent\": \"codex\",
\"prompt\": \"List the top-level files in this repo.\",
\"work_dir\": \"$PWD\"
}"| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Daemon health and active runtime name |
POST |
/sessions |
Create a session from SessionRequest |
GET |
/sessions |
List all known sessions |
GET |
/sessions/:id |
Raw session snapshot from the session manager |
GET |
/sessions/:id/info |
Session summary plus host paths and convenience URLs |
GET |
/sessions/:id/logs?cursor=N |
Incremental replay/log polling; returns Agentruntime-Log-Cursor header |
GET |
/sessions/:id/log |
Full persisted NDJSON log download |
DELETE |
/sessions/:id |
Kill the session and mark it completed/failed |
GET |
/ws/sessions/:id?since=N |
Daemon WebSocket bridge for replay plus stdin |
POST /sessions accepts SessionRequest JSON and returns:
{
"session_id": "7c4f3c3e-8a63-4fe2-baf3-d72b0b7d6458",
"task_id": "optional-task-id",
"agent": "claude",
"runtime": "local",
"status": "running",
"ws_url": "ws://127.0.0.1:8090/ws/sessions/7c4f3c3e-8a63-4fe2-baf3-d72b0b7d6458",
"log_url": "http://127.0.0.1:8090/sessions/7c4f3c3e-8a63-4fe2-baf3-d72b0b7d6458/logs"
}Rules enforced by the daemon today:
agentis required.promptis required unlessinteractiveistrue.runtime, if present, must match the daemon runtime selected at startup.work_diris shorthand for a writable mount to/workspace.work_diris validated: must be absolute, must exist, must be a directory, must not contain sensitive paths (.ssh,.gnupg,.aws,.kube,.docker,.config/gcloud,Library/Keychains), must not contain..traversal, must not be/.
This returns the raw session snapshot from pkg/session, for example:
{
"id": "7c4f3c3e-8a63-4fe2-baf3-d72b0b7d6458",
"task_id": "optional-task-id",
"agent_name": "claude",
"runtime_name": "local",
"session_dir": "/Users/me/.local/share/agentruntime/claude-sessions/7c4f3c3e-8a63-4fe2-baf3-d72b0b7d6458",
"tags": {
"repo": "agentruntime"
},
"state": "running",
"created_at": "2026-03-17T07:00:00Z"
}This returns a friendlier API shape with URLs and host paths:
{
"session_id": "7c4f3c3e-8a63-4fe2-baf3-d72b0b7d6458",
"agent": "claude",
"runtime": "local",
"status": "running",
"created_at": "2026-03-17T07:00:00Z",
"session_dir": "/Users/me/.local/share/agentruntime/claude-sessions/7c4f3c3e-8a63-4fe2-baf3-d72b0b7d6458",
"log_file": "/Users/me/.local/share/agentruntime/logs/7c4f3c3e-8a63-4fe2-baf3-d72b0b7d6458.jsonl",
"ws_url": "ws://127.0.0.1:8090/ws/sessions/7c4f3c3e-8a63-4fe2-baf3-d72b0b7d6458",
"log_url": "http://127.0.0.1:8090/sessions/7c4f3c3e-8a63-4fe2-baf3-d72b0b7d6458/logs"
}This is the public daemon bridge. It is replay-buffer based and intentionally simpler than the sidecar protocol.
Client to daemon:
stdin:{ "type": "stdin", "data": "next line of input\n" }ping:{ "type": "ping" }resize:{ "type": "resize", "cols": 120, "rows": 40 }
Daemon to client:
connectedstdoutreplaypongerrorexit
For sidecar-backed sessions, the stdout and replay payloads are NDJSON event lines produced by the sidecar.
The v2 sidecar has its own WebSocket protocol on /ws. Both the local runtime and Docker runtime use it internally, and you can also use it directly if you run agentruntime-sidecar yourself.
Command envelope:
{
"type": "prompt",
"data": {
"content": "Fix the failing handler."
}
}Event envelope:
{
"type": "agent_message",
"data": {
"text": "Looking at the handler now.",
"delta": true
},
"offset": 284,
"timestamp": 1773732712345
}| Type | Payload | Meaning |
|---|---|---|
prompt |
{ "content": "..." } |
Start a turn or send the first user request |
interrupt |
none | Interrupt the active turn |
steer |
{ "content": "..." } |
Redirect an in-flight turn without starting over from scratch |
context |
{ "text": "...", "filePath": "/workspace/file.go" } |
Inject selected text plus its file path |
mention |
{ "filePath": "/workspace/file.go", "lineStart": 12, "lineEnd": 30 } |
Inject an IDE-style file mention/range |
| Type | Meaning |
|---|---|
agent_message |
Normalized agent text output; includes streaming deltas and final messages |
tool_use |
Normalized tool invocation start |
tool_result |
Normalized tool completion |
result |
Turn/session result summary |
progress |
Intermediate progress from the agent |
system |
Lifecycle or stderr-style system notices |
error |
Protocol or backend error |
exit |
Sidecar process exit notification |
agent_message data:
{
"text": "partial or final text",
"delta": true,
"model": "optional-model-name",
"usage": {
"input_tokens": 123,
"output_tokens": 45
},
"turn_id": "optional-turn-id",
"item_id": "optional-item-id"
}tool_use data:
{
"id": "tool-call-id",
"name": "Bash",
"server": "optional-mcp-server",
"input": {
"command": "git status"
}
}tool_result data:
{
"id": "tool-call-id",
"name": "Bash",
"output": "main.go\nREADME.md\n",
"is_error": false,
"duration_ms": 12
}result data:
{
"session_id": "optional-agent-session-id",
"turn_id": "optional-turn-id",
"status": "success",
"cost_usd": 0.0012,
"duration_ms": 1840,
"num_turns": 1,
"usage": {
"input_tokens": 123,
"output_tokens": 45
}
}exit data:
{
"code": 0,
"error_detail": "optional error message",
"error_category": "auth_error",
"retryable": false
}Error categories (set when the agent session ends with a detectable error):
| Category | Meaning | Retryable |
|---|---|---|
model_not_found |
Requested model does not exist or is inaccessible | No |
auth_error |
Authentication or API key failure | No |
permission_denied |
Insufficient permissions | No |
rate_limit |
API rate limit exceeded | Yes |
duplicate_session |
Session ID already in use | Yes |
upstream_api_error |
Provider API error (500, 503, 529) | Yes |
startup_crash |
Agent produced zero tokens and minimal output — likely crashed before doing work | No |
error_category and retryable are omitted when the session exits cleanly.
Notes:
offsetis a replay byte offset. Reconnect with?since=<offset>to replay from that point.- not every agent emits every event type on every run.
- Claude emits streaming deltas today.
- Claude emits
tool_useevents; Codex emits bothtool_useandtool_result.
Context injection is a sidecar v2 feature, not a daemon /ws/sessions/:id feature. To use it directly, run the sidecar and talk to its /ws endpoint.
Start a sidecar for Claude:
SIDECAR_PORT=9090 \
AGENT_CMD='["claude"]' \
./agentruntime-sidecarSend a text selection:
{
"type": "context",
"data": {
"text": "func handleCreateSession(...) { ... }",
"filePath": "/workspace/pkg/api/handlers.go"
}
}Send a file mention:
{
"type": "mention",
"data": {
"filePath": "/workspace/README.md",
"lineStart": 1,
"lineEnd": 40
}
}Current behavior:
- Claude wires
contextandmentioninto the embedded MCP IDE bridge. - Codex accepts those commands at the sidecar layer but currently logs a warning and does not inject them into the app-server session.
- Prompt mode: set
interactivetofalseor omit it, and includeprompt. The daemon starts the agent, sends the initial request, and closes stdin for one-shot execution. - Interactive mode: set
interactivetotrue. The daemon keeps stdin open, and the agent stays alive for follow-up input. On the daemon bridge, follow-up input usesstdin. On the sidecar/ws, follow-up control usesprompt,interrupt, andsteer. ptyis separate frominteractive. It asks the runtime for a PTY/TTY allocation; it does not change the sidecar protocol.
- Local:
./agentd --runtime local. The runtime startsagentruntime-sidecaron the host and connects to it over localhost. - Docker:
./agentd --runtime docker. The runtime startsagentruntime-agent:latest, waits for the sidecar health endpoint, then connects to the container over its published port. - Legacy local pipe mode still exists as
./agentd --runtime local-pipe, but it bypasses sidecar v2 and does not provide normalized events. New integrations should uselocal.
- ARCHITECTURE.md — System architecture and design decisions
- docs/IMPLEMENTATION-GUIDE.md — Developer reference (session lifecycle, event schema, field reference)
- docs/architecture-flows.md — Detailed sequence diagrams
- docs/guides/lifecycle-hooks.md — Container lifecycle hooks (pre_init, post_init, sidecar, post_run)
- docs/guides/hooks.md — Claude Code tool-use hooks
- docs/specs/ — Design specs (historical)
- docs/research/ — Protocol research references
SessionRequest is the shared request shape used by HTTP, the Go client, and agentd dispatch --config.
{
"task_id": "optional-task-id",
"name": "optional-label",
"tags": {
"repo": "agentruntime",
"ticket": "DOCS-12"
},
"agent": "claude",
"runtime": "local",
"model": "optional-model",
"prompt": "Fix the flaky test.",
"timeout": "5m",
"pty": false,
"interactive": false,
"resume_session": "optional-agent-native-session-id",
"work_dir": "/absolute/path",
"mounts": [
{
"host": "/absolute/path",
"container": "/workspace",
"mode": "rw"
}
],
"volumes": [
"/host/hooks:/hooks:ro"
],
"lifecycle": {
"pre_init": "/hooks/setup.sh",
"post_init": "/hooks/warmup.sh",
"sidecar": "/hooks/watchdog.sh",
"post_run": "/hooks/cleanup.sh",
"hook_timeout": 30
},
"claude": {
"settings_json": {},
"claude_md": "# extra instructions",
"mcp_json": {},
"credentials_path": "~/.claude/credentials.json",
"memory_path": "~/.claude/projects",
"output_format": "stream-json"
},
"codex": {
"config_toml": {},
"instructions": "# extra instructions",
"approval_mode": "suggest"
},
"mcp_servers": [
{
"name": "docs",
"type": "http",
"url": "http://${HOST_GATEWAY}:8080",
"token": "optional-token"
}
],
"env": {
"OPENAI_API_KEY": "set-me"
},
"container": {
"image": "agentruntime-agent:latest",
"memory": "4g",
"cpus": 2,
"security_opt": [
"label=disable"
]
}
}Fields that matter most in practice:
agent: currentlyclaudeorcodexfor the v2 sidecar path.promptplusinteractive: choose one-shot or interactive behavior.work_diror writablemounts: controls/workspace. For Docker runtime, omittingwork_dirmeans no host volume is mounted — the agent works inside the container's own filesystem.volumes: convenience string array using Docker'shost:container[:mode]syntax. Merged withmounts.lifecycle: container lifecycle hooks —pre_init,post_init,sidecar,post_run. See lifecycle hooks guide.claudeandcodex: file materialization into~/.claudeor~/.codex. If omitted, the daemon infers a default empty config block from theagentfield so credentials and config files are still materialized. Explicitly sending"codex": {}or"claude": {}is equivalent but makes the intent clear.mcp_servers: merged into Claude MCP config and sanitized during materialization.env: explicit env vars for the runtime.container.image,container.memory,container.cpus,container.security_opt: Docker-specific controls that are applied today.
Important implementation notes:
work_diris shorthand for{ "host": work_dir, "container": "/workspace", "mode": "rw" }.- If both
work_dirandmountsare present, both are used. runtimeis optional and must match the daemon runtime if you send it.${HOST_GATEWAY}is resolved inside MCP server URLs during materialization.- The schema currently accepts a few forward-compatible fields that are not wired through end-to-end by
agentdyet: top-levelname,model, andtimeout;claude.output_format;codex.approval_mode; andcontainer.network.
{
"agent": "claude",
"prompt": "Summarize the architecture of this repo in one paragraph.",
"work_dir": "/Users/me/Toolkit/agentruntime",
"claude": {
"claude_md": "Stay focused on this repository."
}
}agent: codex
interactive: true
work_dir: /Users/me/Toolkit/agentruntime
codex:
instructions: |
You are working inside the agentruntime repository.
container:
image: agentruntime-agent:latest
memory: 4g
cpus: 2
env:
OPENAI_API_KEY: ${OPENAI_API_KEY}