BlackboxServer

July 24, 2026 · View on GitHub

Unified HTTP Adapter for Agentic Blackbox Backends

← Back to Main README · Overview · Key Features · Backends · Architecture · API Reference · Data Flow · Quick Start

📖 Overview

BlackboxServer is a bundled HTTP adapter service that decouples the Dressage rollout manager from concrete agentic backends. It sits inside sandboxes, manages exactly one backend agent process and one active session at a time, and transparently proxies all LLM calls back through the Dressage inference proxy.

The key insight: agent frameworks like opencode, openclaw, Claude Code, and Codex each have their own CLI interfaces, configuration formats, and communication protocols. BlackboxServer provides a uniform HTTP interface that the paddock can drive regardless of which backend is behind it. This is what makes it possible to swap agent frameworks with a single environment variable.

blackbox_dispatch (rollout hook)
        │  paddock.register_agent / call_agent / pause / resume

BlackboxServer :23456 (inside sandbox)
        │  one backend agent process
        │  one active session at a time
        │  in-process LLM proxy → Dressage Proxy

opencode serve / openclaw gateway / claude -p / codex exec / …
        │  agent makes LLM calls
        │  proxy injects session headers

Dressage Proxy → ⚡ SGLang Router

Important

BlackboxServer runs inside each sandbox slot. In local bubblewrap mode, each bwrap namespace has its own BlackboxServer process. In E2B mode, the server runs as a service in the cloud sandbox. The paddock communicates with it via HTTP from outside the sandbox.

✨ Key Features

  • Multi-Backend Support — Pluggable adapter pattern supports opencode (code-editing agent via opencode serve), openclaw (OpenClaw Gateway via /v1/chat/completions), claude_code (Claude Code headless CLI via Anthropic Messages), and codex (Codex CLI via codex exec --json). Adding a new backend means implementing a single BackendAdapter class with initialize, send_message, abort_session, health, and capabilities methods; pause / resume can be overridden when the backend supports them.
  • In-Process LLM Proxy — Every BlackboxServer instance runs a lightweight HTTP proxy that intercepts all outgoing LLM calls from the backend agent. This proxy injects session headers (X-Session-Id, X-Instance-Id, X-Turn-Id), routing keys, and partial rollout markers transparently. The agent never knows its calls are being recorded.
  • Turn Idempotency — Each turn is identified by a (turn_id, messages_hash) tuple. Retrying the same turn with the same messages replays the cached result (committed) or re-attaches to the in-progress execution (queued/inflight) without starting a second agent run; retrying with different messages returns 409 Conflict. This makes both synchronous calls and asynchronous submit retries safe without duplicating agent work.
  • Sync + Async turnsPOST /messages accepts mode="sync" (default, request-bound) or mode="async" (submit + 202, then long-poll GET /turns/{turn_id}). At most one turn is active per session; execute_cmd is rejected while a turn is active.
  • Register & Rebind — Registration is idempotent: calling POST /v1/rollout/register with the same parameters while the server is ready is a no-op. If parameters change, the server returns 409 Conflict while active or desynced sessions still exist; only after no open sessions remain can it tear down and re-initialize with the new configuration.
  • Health Monitoring — A background poller periodically checks the backend agent process health. If the agent crashes or becomes unresponsive, the session is marked as desynced and the paddock is informed. This prevents silent failures where the agent dies but the rollout keeps waiting.
  • Single-Session Guarantee — One server instance manages exactly one agent process and one active session. This ensures clean turn-context attribution: every LLM call within a session is guaranteed to carry the correct session/turn headers. For parallel rollout, deploy one BlackboxServer per sandbox slot.

🔌 Supported Backends

BackendStatusDescriptionHow It Works
opencodeImplementedCode-editing agentSpawns opencode serve as a subprocess. Sends tasks via /api/chat endpoint. Agent writes code, runs tests, iterates.
openclawImplementedOpenClaw Gateway agentConnects to OpenClaw Gateway's /v1/chat/completions. Agent uses OpenClaw's tool ecosystem for complex tasks.
claude_codeImplementedClaude Code agentRuns claude -p --output-format stream-json --verbose; proxy bridges Anthropic Messages to OpenAI-compatible chat completions.
codexImplementedCodex CLI coding agentRuns codex exec --json; the adapter writes an isolated CODEX_HOME/config.toml with a Chat Completions custom provider pointing at the in-process proxy.

Adding a New Backend

To add a new backend, create a class that extends BackendAdapter in blackbox_server/adapters/:

class MyBackendAdapter(BackendAdapter):
    async def initialize(self, binding_context: BindingContext) -> None:
        """Initialize runtime state, proxy config, and backend process."""
        ...

    async def send_message(
        self,
        session_context: SessionContext,
        turn_context: TurnContext,
        new_messages: list[Message],
    ) -> AdapterResponse:
        """Send a user turn and wait for completion."""
        ...

    async def abort_session(self, session_context: SessionContext) -> bool:
        """Abort the active backend session."""
        ...

    async def health(self) -> bool:
        """Check if the agent process is alive."""
        ...

    async def capabilities(self) -> BackendCapabilities:
        """Report supported protocol capabilities."""
        ...

Register the adapter in blackbox_server/adapters/factory.py and it will be available via DRESSAGE_BLACKBOX_TYPE.

🏗️ Architecture

The BlackboxServer has a layered internal architecture — FastAPI routes on top, server core in the middle, and the backend adapter + LLM proxy at the bottom:

┌───────────────────────────────────────────────────┐
│              BlackboxServer :23456                │
│                                                   │
│  ┌──────────────────────────────────────────────┐ │
│  │            FastAPI App (api/)                │ │
│  │                                              │ │
│  │     /v1/rollout/register        → register   │ │
│  │     /v1/sessions/{id}/messages  → send turn  │ │
│  │     /v1/sessions/{id}/execute_cmd → shell    │ │
│  │     /v1/sessions/{id}/abort     → abort      │ │
│  │     /health                      → liveness  │ │
│  │     /v1/status                  → full state │ │
│  └──────────────┬───────────────────────────────┘ │
│                 │                                 │
│  ┌──────────────▼───────────────────────────────┐ │
│  │            Server Core (core/)               │ │
│  │                                              │ │
│  │     Register / rebind logic                  │ │
│  │     Session store (in-memory, single session)│ │
│  │     Turn idempotency ledger                  │ │
│  │     Backend health monitor (background task) │ │
│  │     Config change detection (hashing)        │ │
│  └──────────────┬───────────────────────────────┘ │
│                 │                                 │
│  ┌──────────────▼───────────────────────────────┐ │
│  │           Backend Adapter (adapters/)        │ │
│  │                                              │ │
│  │     Spawn agent subprocess                   │ │
│  │     Send messages / receive responses        │ │
│  │     Manage agent lifecycle                   │ │
│  │     Set turn context on LLM proxy            │ │
│  └──────┬───────────────┬───────────────────────┘ │
│         │               │                         │
│  ┌──────▼─────┐         │    control              │
│  │ LLM        │         │ (start/stop/context)    │
│  │ Proxy      │         │                         │
│  │ :AUTO_PORT │         │                         │
│  │            │         │                         │
│  │ → session  │         │                         │
│  │   headers  │         │                         │
│  │ → routing  │         │                         │
│  │   headers  │         │                         │
│  └──────┬─────┘         │                         │
│         │               │                         │
└─────────┼───────────────┼─────────────────────────┘
          │               │ subprocess
          │        ┌──────▼────────────┐
          │        │    opencode serve │
          │        │    or openclaw    │
          │        │    gateway        │
          │        │                   │
          │        │ baseURL → proxy   │
          │        └──────┬────────────┘
          │               │
          └───────────────┘
                  │ HTTP /v1/chat/completions
          ┌──────▼────────────────────────┐
          │       Dressage Proxy          │
          │       SGLang Router           │
          └───────────────────────────────┘

🌐 API Reference

Management Endpoints

MethodPathPurposeDetails
GET/healthLiveness checkReturns 200 if the server is running. Used by supervisor for health monitoring.
GET/v1/statusFull server stateReturns binding info, session state, turn count, backend health, uptime.
POST/v1/rollout/registerRegister backendIdempotent registration. Starts agent process and LLM proxy. Returns session binding info.
POST/v1/rollout/pausePause generationForwards pause signal to proxy's GenerationController.
POST/v1/rollout/resume▶ Resume generationForwards resume signal after weight update completes.
GET/v1/rollout/pause_statePause stateReturns the current pause/resume state and in-flight request counters.

Session Endpoints

MethodPathPurposeDetails
POST/v1/sessions/{id}/messagesSubmit a user turnBody mode selects behaviour. mode="sync" (default) blocks until completion and returns the turn result (fully backward compatible). mode="async" accepts the turn and returns 202 Accepted with {turn_id, status}; turn_id is required in async mode. Turn idempotency keyed on (turn_id, messages_hash).
GET/v1/sessions/{id}/turns/{turn_id}Poll turn statusLong-poll a submitted turn. Optional ?wait=<seconds> (clamped to 60) blocks until the turn settles or the wait expires (then returns the current queued/inflight snapshot — never 504). Terminal turns return outputs/usage/backend (committed) or error (failed/unknown/cancelled).
POST/v1/sessions/{id}/turns/{turn_id}/cancelCancel a turnqueued turns are cancelled synchronously (cancelled); inflight turns are cancelled best-effort (cancel_requested); terminal turns return their current state idempotently.
POST/v1/sessions/{id}/execute_cmdExecute commandRun a shell command inside the sandbox. Rejected with 409 while a turn is queued/inflight (at most one active turn per session). Returns stdout/stderr.
GET/v1/sessions/{id}Get session infoReturns session state, turn history, and metadata.
POST/v1/sessions/{id}/abortAbort sessionCleanly stops the agent session, cancels any active turn, and marks it as aborted.

🔄 Data Flow

1 Registration

When the paddock calls POST /v1/rollout/register, the BlackboxServer performs a multi-step initialization:

Register Request (blackbox_type, router, bound_session_id, bound_instance_id, ...)

        ├── Hash config → compare with current binding
        │   ├── Same config? → no-op, return existing binding
        │   └── Different config? → full teardown + re-init

        ├── Start in-process LLM proxy on auto-assigned port
        │   └── Configure: upstream_url → Dressage Proxy URL
        │   └── Configure: session headers, routing key

        ├── Start backend agent subprocess
        │   └── Set agent's baseURL → LLM proxy address
        │   └── Wait for agent health check to pass

        └── Create session in session store
            └── Session state = "active"

2 Turn Execution

A turn is submitted via POST /v1/sessions/{id}/messages. Submission is a short critical section that accepts the turn and starts a background execution task; the actual backend call runs outside the session lock. mode="sync" blocks the HTTP request until the turn settles (backward compatible); mode="async" returns 202 immediately and the client polls GET /v1/sessions/{id}/turns/{turn_id}.

Turn Submit (turn_id, messages, mode)

        ├── Admission (under session lock)
        │   ├── Same (turn_id, messages_hash)?
        │   │   ├── committed        → idempotent replay of the cached result
        │   │   ├── queued/inflight  → re-attach to the same execution (no new work)
        │   │   └── terminal error   → return the existing terminal record
        │   ├── Same turn_id, different messages? → 409 Conflict
        │   └── Different turn_id while one is active? → 409 Conflict (active_turn_id)

        ├── Create TurnRecord(status=queued) + completion event
        ├── Spawn background task, release lock, return (202 for async)

        └── Background task (no session lock held)
            ├── status → inflight
            ├── Set proxy context → (session_id, turn_id)
            ├── Forward messages to backend agent (pause-aware, backend_timeout)
            │   └── Agent makes LLM calls → proxy → SGLang (recorded)
            ├── Re-acquire lock to commit:
            │   ├── success           → status committed + response
            │   ├── timeout           → status unknown (session desynced, 504)
            │   ├── overflow/steps    → status unknown (413 / 429)
            │   └── cancel/shutdown   → status cancelled / unknown
            └── Set completion event (wakes sync waiters and long-polls)

Turn status lifecycle: queued → inflight → {committed | failed | cancelled | unknown}. error.http_status on a terminal record preserves the original synchronous HTTP status so both the sync facade and polling clients surface identical errors.

3 Session Termination

Abort Request

        ├── Signal backend agent to stop
        ├── Mark session as "aborted" in store
        └── Clear proxy context

📡 In-Process LLM Proxy

The LLM proxy is a critical component that runs inside each BlackboxServer instance. It intercepts every outgoing LLM call from the backend agent and injects Dressage-specific headers before forwarding to the Dressage proxy. The agent is unaware that its calls are being intercepted.

Injected Headers

HeaderPurposeExample
<sticky_header_name>Session routing key (configurable, e.g., X-SMG-Routing-Key)sess-001
X-Session-IdSession identifier for trajectory attributionsess-001
X-Instance-IdInstance identifier for prompt-equal scalinginst-xyz
X-Turn-IdCurrent turn identifier for step orderingturn-003
X-Dressage-Partial-RolloutInjected as 1 on proxied chat calls from BlackboxServer1

Tip

Token-version behavior is controlled by Dressage proxy startup flags such as --dressage-partial-rollout, --record-token-versions, and --mask-nonlast-version-tokens. The X-Dressage-Partial-Rollout header is injected by BlackboxServer but is not the feature toggle.

📋 Session States

A session progresses through defined states with clear transition rules:

StateDescriptionAllowed Transitions
activeSession is healthy and accepting turns. Agent process is alive, proxy is routing correctly.desynced (on failure) → aborted (on explicit abort)
desyncedA turn failed in unknown state — the agent may have partially executed, making turn attribution unreliable. Cannot accept new turns.aborted (must abort to recover)
abortedSession has been cleanly or forcibly terminated. Agent process has stopped.Terminal state — create a new session.

Caution

desynced is a terminal state for the session. The agent may have made partial progress that the proxy can't attribute correctly. The only safe recovery is to abort the session and create a fresh one. The paddock handles this automatically by calling terminate and re-initializing.

🚀 Quick Start

Starting the Server

# Via CLI entry point
blackbox-server

# Via Python module
python -m blackbox_server.main

Register a Backend

Registration requires blackbox_type, router, bound_session_id, and bound_instance_id. Optional request fields include router_api_path, system_prompt_file, backend_options, and server_config.

curl -X POST http://127.0.0.1:23456/v1/rollout/register \
    -H 'Content-Type: application/json' \
    -d '{
      "blackbox_type": "opencode",
      "router": "http://<dressage-proxy-host>:<port>",
      "router_api_path": "/v1",
      "bound_session_id": "sess-001",
      "bound_instance_id": "inst-001",
      "backend_options": {
        "provider_id": "sglang",
        "provider_name": "Dressage Proxy",
        "provider_package": "@ai-sdk/openai-compatible",
        "model_id": "proxy-model",
        "model_name": "Dressage Proxy",
        "proxy": {
          "sticky_header_name": "X-SMG-Routing-Key",
          "max_steps": 100,
          "default_temperature": 1.0
        }
      }
    }'

Send a Message (synchronous)

curl -X POST http://127.0.0.1:23456/v1/sessions/sess-001/messages \
  -H 'Content-Type: application/json' \
  -d '{
    "turn_id": "turn-001",
    "messages": [{"role": "user", "content": "Fix the bug in main.py"}]
  }'

Send a Message (asynchronous submit + poll)

# Submit (turn_id is required for async) -> 202 Accepted
curl -X POST http://127.0.0.1:23456/v1/sessions/sess-001/messages \
  -H 'Content-Type: application/json' \
  -d '{
    "turn_id": "turn-001",
    "mode": "async",
    "messages": [{"role": "user", "content": "Fix the bug in main.py"}]
  }'

# Long-poll until the turn settles (wait is clamped to 60s)
curl "http://127.0.0.1:23456/v1/sessions/sess-001/turns/turn-001?wait=30"

# Cancel an in-flight turn (best effort)
curl -X POST http://127.0.0.1:23456/v1/sessions/sess-001/turns/turn-001/cancel

Check Status

curl http://127.0.0.1:23456/v1/status | python -m json.tool

⚙️ Environment Variables

Server Configuration
VariableDefaultDescription
BBS_HOST0.0.0.0Bind host for the FastAPI server
BBS_PORT23456Bind port for the FastAPI server
BBS_RUNTIME_ROOT/tmp/blackbox_serverRoot directory for runtime files (logs, PID files, etc.)
BBS_MAX_SESSIONS1Maximum tracked sessions. Should always be 1 for single-session guarantee.
BBS_MAX_TURNS200Maximum turns per session before forced termination
BBS_BACKEND_TIMEOUT960.0Timeout for agent calls in seconds (16 minutes default)
BBS_EXECUTE_CMD_TIMEOUT600.0Timeout for execute_cmd calls in seconds (10 minutes default)
BBS_ROUTER_TIMEOUT600000Timeout for upstream router requests from the in-process LLM proxy
BBS_SHUTDOWN_TIMEOUT30.0Grace period for shutdown in seconds
BBS_RUNTIME_HEALTH_CHECK_INTERVAL10.0Interval between backend runtime health checks
BBS_RUNTIME_HEALTH_CHECK_RETRIES3Runtime health-check retry count
BBS_RUNTIME_HEALTH_CHECK_RETRY_DELAY0.5Delay between runtime health-check retries
OPENCODE_BINopencodePath to the opencode binary
OPENCLAW_BINopenclawPath to the openclaw binary
CLAUDE_CODE_BINclaudePath to the Claude Code binary
CODEX_BINcodexPath to the Codex CLI binary

Note

BlackboxServerConfig has an internal class field default of 8080, but runtime configuration is loaded through from_env(), where BBS_PORT defaults to 23456.

Backend Proxy Options

backend_options.proxy can tune the in-process LLM proxy:

FieldDefaultDescription
sticky_header_nameX-SMG-Routing-KeyHeader used for sticky routing/session affinity.
max_steps100Maximum proxied LLM calls before the turn is treated as max-step exceeded.
default_temperaturenullDefault temperature injected when the backend omits one.

When BlackboxServer registration is built through Dressage paddock defaults, DRESSAGE_BLACKBOX_TYPE defaults to opencode. DRESSAGE_BLACKBOX_MAX_STEPS is forwarded into backend_options.proxy.max_steps as a positive integer, and 0 disables the limit. DRESSAGE_BLACKBOX_COMPACT_THRESHOLD must be positive and no greater than the context window; it controls backend compaction reserve sizing.

⚠️ Important Notes

RuleDescription
One server = one agentFor parallel rollout, deploy one BlackboxServer per sandbox slot. The bwrap pool does this automatically.
One bound sessionThe LLM proxy holds a single turn context — multiple concurrent sessions would corrupt turn attribution.
No inline system promptsSystem prompts are configured via system_prompt_file at registration time, not in per-turn messages.
Claude Code uses Anthropic MessagesThe claude_code adapter points Claude Code at the in-process proxy, which accepts /v1/messages, forwards OpenAI-compatible /v1/chat/completions to Dressage, and converts responses back.
Codex uses isolated local stateThe codex adapter sets sandbox-local CODEX_HOME / CODEX_SQLITE_HOME, removes inherited Codex/OpenAI auth env vars, and does not mount host ~/.codex.
Codex defaults to full access inside the outer sandboxThe codex backend defaults to sandbox_mode=danger-full-access and approval_policy=never; use it only inside Dressage's sandbox boundary or override backend_options for a stricter mode.
Rebinding conflicts while openChanging registration parameters returns 409 Conflict while active or desynced sessions still exist. Rebind only proceeds after no open sessions remain.
Desynced is terminalA desynced session cannot accept new turns. Abort and create a fresh session.
Timeouts are generousDefault 16-minute backend timeout accommodates complex coding tasks. Adjust for your workload.

📁 Module Structure

blackbox_server/
├── api/                    # FastAPI route handlers
│   ├── rollout.py             #   Registration, pause, resume endpoints
│   ├── sessions.py            #   Session message, execute_cmd, abort
│   └── health.py              #   Health and status checks
├── adapters/                # Backend implementations
│   ├── base.py                #   Abstract BackendAdapter interface
│   ├── opencode.py            #   opencode adapter (subprocess management)
│   ├── openclaw.py            #   openclaw adapter (gateway client)
│   ├── claude_code.py         #   Claude Code CLI adapter
│   ├── codex.py               #   Codex CLI adapter
│   └── factory.py             #   Adapter factory (type → class mapping)
├── core/                   # Server logic
│   ├── server.py              #   BlackboxServer core (register, rebind, health)
│   ├── models.py              #   Request/response Pydantic models
│   ├── monitoring.py          #   Background health monitor
│   ├── hashing.py             #   Config change detection via SHA hashing
│   ├── command.py             #   Shell command execution utilities
│   └── errors.py              #   Error types and error code mapping
├── proxy/                  # In-process LLM proxy
│   └── rollout_llm_proxy.py   #   HTTP proxy with header injection
├── store/                  # Session store
│   └── session_store.py       #   In-memory session + turn ledger
├── runtime/                # Path and runtime ID resolution
│   └── paths.py               #   Runtime directory layout
├── app.py                  # FastAPI app factory
├── config.py               # Configuration from environment
└── main.py                 # CLI entry point

🔗 Integration Points

ComponentRelationship
PaddockBlackbox paddock drives the full BlackboxServer lifecycle via HTTP
ProxyLLM proxy forwards all agent calls through Dressage proxy for token recording
SandboxBlackboxServer runs inside sandbox slots (bwrap or E2B)
Rolloutblackbox_dispatch generate function orchestrates the paddock → BlackboxServer flow

← Sandbox · Back to Main README · Next: Rollout →