Skip to main content

spens-acp

A shim that exposes agents running in the spens sandbox over the Agent Client Protocol (ACP).

Overview

spens is a sandbox for running AI coding agents (claude, codex, opencode, pi, …) inside isolated Docker containers with full network interception, egress policy, and audit logging — every LLM call, tool invocation, and file change is captured and reviewable.

spens-acp is the bridge between your editor and that sandbox. It speaks ACP — JSON-RPC 2.0 over stdio — on one side, and drives the spens CLI on the other. Any ACP-compatible client (Zed, VS Code with an ACP extension, …) can then use spens-sandboxed agents from its normal agent panel: the editor thinks it is talking to an ordinary ACP agent, while every prompt actually runs as a fully sandboxed, intercepted, auditable spens session.

What you get:

  • Editor-native agent UX — chat, streamed output, tool activity, and cancellation from your editor's agent panel, for any agent spens supports.
  • Sandboxing by default — each prompt runs in an ephemeral container with network policy, secret injection, and audit logs (see the spens docs).
  • Continuous conversations — consecutive prompts in a thread behave like one agent session (history is replayed as context), and sessions survive editor restarts (session/resume).
  • Full observability — everything spens captures (chat transcripts, HTTP traffic, file changes) is still available via spens log-viewer.

Quick start

Prerequisites

  • Python 3.12+
  • The spens CLI installed and on $PATH, with Docker working (spens list should print environments and agents)
  • An ACP-capable editor: Zed (native support) or VS Code with an ACP client extension

1. Install spens-acp

From a checkout of this repository:

# with uv (recommended) — installs the `spens-acp` command onto your PATH
uv tool install .

# or with pip
pip install .

For development:

uv sync                 # create .venv from uv.lock
uv run spens-acp --help

Verify the install (and that spens is reachable):

spens-acp --help

2. Configure the project you want to work on

For each project/workspace you want to drive through spens-acp, add default_env and default_agent to .spens.config.json at the project root (create the file if it does not exist):

{
  "default_env": "python-3.12",
  "default_agent": "pi"
}
  • default_env — the spens environment (container image + toolchain) to run in. Built-ins: node-20, node-22, node-24, python-3.11, python-3.12, python-3.13. Run spens list to see everything available.
  • default_agent — the agent to run. Built-ins: claude, codex, opencode, pi.

The workspace config is the recommended way to configure spens-acp: one file per project, and every editor/client that opens the project picks it up automatically. (You can instead pass --env/--agent flags or SPENS_ENV/SPENS_AGENT env vars per launch — see Configuration — but then you must configure each editor entry separately.)

.spens.config.json is also spens' own config file. Keys like domain_rules, inject_headers, env, and addition_capture_urls configure the sandbox itself — see the spens docs. spens-acp only reads default_env / default_agent from it.

3. Smoke test (no editor needed)

Run one real prompt through the whole pipeline to confirm the wrapper, spens, and your project config all work:

cd /path/to/your/project
spens-acp --smoke-test "reply with hello"

It drives initialize / session/new / session/prompt against your real spens binary in the current directory and prints every session update to stderr. If this passes, the stack is healthy and any editor problem is between the editor and the wrapper (see Troubleshooting).

4. Zed

Zed has native ACP support. Agents are configured under External Agents:

  1. Open Zed's settings (zed: open settings, or cmd-,).
  2. Go to AI → General → External Agents (or run agent: open settings from the command palette and pick the External Agents page).
  3. Click Add Agent → Add Custom Agent — Zed opens your settings.json with an agent_servers entry to fill in:
{
  "agent_servers": {
    "spens": {
      "type": "custom",
      "command": "spens-acp",
      "args": [],
      "env": {
        "FIREWORKS_API_KEY": "fw-…"
      }
    }
  }
}

Everything in the env block is passed to the spens-acp process (and inherited by the spens sessions it launches). Use it for:

  • Provider API keys — FIREWORKS_API_KEY, ANTHROPIC_API_KEY, etc. The real key stays on the host: pair it with an inject_headers rule in the project's .spens.config.json, and spens' interceptor substitutes it into requests to the authorized domains only — the key itself never enters the sandbox (see the spens docs).
  • Any SPENS_* option from the options table — e.g. SPENS_ENV / SPENS_AGENT to pin an env/agent for this editor entry instead of relying on the workspace config, SPENS_BIN if the spens binary is not on Zed's PATH, or SPENS_DEBUG=1 while setting things up.

Zed picks up settings changes automatically — no restart needed. Then:

  1. Open your project folder in Zed (the folder containing .spens.config.json).
  2. Open the agent panel (cmd-? on macOS, ctrl-? elsewhere).
  3. Click + to start a new thread and select spens as the agent.
  4. Chat — each message runs one sandboxed spens session in the workspace.

If spens-acp is not on Zed's PATH (e.g. it lives in a venv), point command at the interpreter instead:

"command": "python3", "args": ["-m", "spens_acp"]

5. VS Code

VS Code needs an ACP client extension — it does not ship with one. Install ACP Client (formulahendry.acp-client) from the VS Code Marketplace (alternatives exist, e.g. strato-space.acp-plugin, which uses the same Zed-style agent_servers format).

Add the agent to your VS Code settings.json:

{
  "acp.agents": {
    "spens": {
      "command": "spens-acp",
      "args": [],
      "env": {
        "FIREWORKS_API_KEY": "fw-…"
      }
    }
  }
}

The env block works exactly like Zed's — pass provider API keys (paired with an inject_headers rule in the project's .spens.config.json) and any SPENS_* options there.

Then:

  1. Open the ACP panel from the Activity Bar (ACP icon).
  2. Connect to the spens agent.
  3. Open your project folder and start chatting.

The extension spawns the agent process from the VS Code process itself — make sure spens-acp (and spens) are on its PATH. Launch VS Code from a shell where they are installed, or use an absolute path in command.

Running it manually

spens-acp --env python-3.12 --agent pi
# or as a module:
python3 -m spens_acp --env python-3.12 --agent pi
# or via environment variables:
SPENS_ENV=python-3.12 SPENS_AGENT=pi spens-acp

This speaks JSON-RPC 2.0 on stdio — it is the interface editors spawn, not meant for interactive use. Use --smoke-test for a human-readable run.

Configuration / options

Environment / agent resolution

Precedence (first match wins):

  1. session/new params (env / agent, top-level or in _meta)
  2. Explicit argument — --env / --agent or SPENS_ENV / SPENS_AGENT
  3. Workspace config — <workspace>/.spens.config.json keys default_env / default_agent
  4. Error — "No environment/agent configured: pass --env/--agent or set default_env/default_agent in .spens.config.json"

All options

Env var / flag Default Meaning
SPENS_BIN spens Path to spens binary
SPENS_ENV / --env — Explicit environment
SPENS_AGENT / --agent — Explicit agent
SPENS_CHANGES accept accept | reject → --accept-changes / --reject-changes
SPENS_REBUILD auto auto (never pass flag) | always (pass --rebuild)
SPENS_DIR <workspace>/.spens Override --spens-dir
SPENS_INCLUDE_AGENT_OUTPUT false Stream agent_output events as thought chunks
SPENS_EMIT_SUMMARY true Emit final summary thought chunk (tokens / cost / files)
SPENS_TOOL_RESULT_MAX 2000 Truncate tool-result content in updates
SPENS_HISTORY true Replay earlier turns of the ACP session as transcript context in each spens prompt (continuous-session behavior)
SPENS_HISTORY_MAX_TURNS 20 Maximum earlier turns included in the replayed transcript
SPENS_HISTORY_MAX_CHARS 24000 Character budget for the replayed transcript; oldest turns are dropped (whole turns) until it fits
SPENS_RESUME true Persist session state to <spens-dir>/acp-sessions/ and support session/resume
SPENS_POLL_INTERVAL 0.25 File tailer poll interval (seconds)
SPENS_LAUNCH_TIMEOUT 60 Seconds to wait for spens --output background to print the session id
SPENS_SESSION_DIR_TIMEOUT 60 Seconds to wait for the session directory to appear after a confirmed launch
SPENS_CANCEL_ON_EXIT true SIGTERM/SIGINT/EOF → spens cancel in-flight sessions
SPENS_DEBUG — Set to 1 for verbose stderr diagnostics (argv, events, state transitions)

Example workspace config

A real-world .spens.config.json (this repo's own), combining the spens-acp keys with spens' sandbox policy keys:

{
  "default_env": "python-3.12",
  "default_agent": "pi",
  "addition_capture_urls": ["*api.fireworks.ai*", "*api.anthropic.com*"],
  "env": ["FIREWORKS_BASE_URL", "FIREWORKS_ACCOUNT_ID"],
  "domain_rules": [
    { "pattern": "*pypi.org", "allow": ["GET", "HEAD", "POST"] },
    { "pattern": "*api.anthropic.com", "allow": ["GET", "POST", "PUT", "DELETE", "OPTIONS"] }
  ],
  "inject_headers": [
    {
      "placeholder": "FIREWORKS_API_KEY",
      "env_var": "FIREWORKS_API_KEY",
      "for_domains": ["*api.fireworks.ai*"]
    }
  ]
}

Only default_env / default_agent are read by spens-acp; the rest is spens' own configuration (see the spens docs).

Continuous sessions (history replay)

spens itself is stateless — each prompt turn launches a fresh spens session. To make an ACP session behave like one continuous conversation, the wrapper records every completed exchange (user prompt + final assistant text from captured.jsonl) and replays them as a compact transcript prefix on the next prompt:

You are continuing an existing conversation. ...

User: <earlier prompt>
Assistant: <earlier reply>

User: <new prompt>

Only successful (end_turn) turns are recorded; failed or cancelled launches are not replayed. The transcript is bounded by SPENS_HISTORY_MAX_TURNS and SPENS_HISTORY_MAX_CHARS — oldest turns are dropped whole, with an [... N earlier turn(s) omitted ...] marker — and an oversized single exchange is mid-truncated with .... Disable the whole feature with SPENS_HISTORY=0 (every prompt then launches spens with the bare user message).

Resuming sessions (session/resume)

The wrapper process is itself stateless — when the client (editor) restarts or reconnects, it spawns a fresh spens-acp with no memory. To let those sessions continue, every ACP session's state (workspace cwd, resolved env/agent, the replay-turn history, and the spens session ids already used) is mirrored to a small JSON file:

<spens-dir>/acp-sessions/<sessionId>.json

Writes are atomic and best-effort — a failed write never fails the prompt it serves, it only costs resumability. session/resume (which initialize advertises via sessionCapabilities.resume) reloads that file in a fresh process, re-validates the binary and env/agent, and restores the history so the next prompt continues the conversation. The spens-id sequence continues too, so a resumed turn never reuses a session directory already on disk.

Notes:

  • A session can only be resumed in the workspace it was created in (the cwd of the resume request must match); with SPENS_DIR pointing at a shared directory the state is found there instead.
  • The spens id is persisted before each launch, so even a crash mid-turn cannot make a later resume reuse the id.
  • Disable with SPENS_RESUME=0: no state is written, the capability is not advertised, and cross-process resume answers invalid params (same-process resume of a live session still works).
  • session/resume is still flagged unstable in this SDK build, so the agent enables use_unstable_protocol on its router; unhandled unstable methods still answer method not found individually.

Architecture

ACP client (Zed / VS Code / …)
   │  JSON-RPC 2.0 over stdio (official agent-client-protocol SDK)
   ▼
spens-acp  (Python; acp.Agent implementation)
   │  subprocess (argv list, no shell)         polling tailers
   ├──────────────────────────►  spens CLI ──► <spens-dir>/sessions/<id>/
   │                                │            events.jsonl  (lifecycle)
   │                                │            state.json    (state machine)
   │                                │            traces/captured.jsonl (LLM traffic)
   ▼
 session/update notifications  ◄──  decode + map

How it works

The wire protocol is implemented with the official agent-client-protocol SDK (acp.run_agent + AgentSideConnection), which provides the cross-platform stdio transport (thread-based feeder on Windows, connect_read_pipe on POSIX), JSON-RPC framing, request dispatch, and schema validation. spens_acp.acp_server.SpensAgent implements the acp.Agent protocol; internal update dataclasses from the decoders are converted to SDK SessionUpdate types at the boundary.

Life of a prompt:

  1. session/new — the agent generates an sessionId, records the workspace cwd, and resolves env/agent (session params → flags/env vars → workspace .spens.config.json), validating them against spens list.
  2. session/prompt — the prompt is flattened to a string, earlier turns of the conversation are prepended as a transcript prefix (history replay), and one spens session is launched in yolo mode: spens <env> <agent> <workspace> "<prompt>" --output background --accept-changes --session-id <id> (spawned as an argv list, no shell).
  3. While it runs, three concurrent watchers poll the session directory on disk: events.jsonl (lifecycle events → tool-call / plan / thought updates), state.json (state machine → terminal-state detection), and traces/captured.jsonl (raw LLM API traffic → streamed agent_message_chunk updates via provider-specific decoders).
  4. Every decoded update is sent to the client as a session/update notification; a framing spens <env>/<agent> tool call shows activity from the moment of launch.
  5. Terminal state (finished / canceled / error) → the tool call is closed, the exchange is recorded in the session history, and session/prompt returns a PromptResponse with stopReason (end_turn / cancelled; an error state fails the request with a JSON-RPC error instead).
  6. session/cancel — calls spens cancel <id>; the in-flight prompt resolves with stopReason: "cancelled".

The wrapper is intentionally loose — it couples only to spens' documented CLI surface and on-disk session files. It never imports spens as a library, and degrades gracefully when spens emits records it does not recognise (skip + log, never crash).

ACP surface

Method Behaviour
initialize Echoes the client's protocolVersion, advertises agentInfo, validates spens binary and explicit env/agent config
session/new Agent generates sessionId (official protocol). Records workspace cwd, resolves env/agent from params, workspace config, or error
session/resume Restores a session in a fresh wrapper process from persisted state; validates cwd/env/agent, continues the conversation and spens-id sequence
session/prompt Flattens prompt → string, prepends replayed session history, launches one spens yolo session, streams session/update notifications, returns PromptResponse
session/cancel Notification. Calls spens cancel <id>; in-flight session/prompt resolves with stopReason: "cancelled"

Not implemented: session/set_mode, session/load, file-system methods, permission requests → JSON-RPC method not found (-32601).

Provider format support

spens' interceptor captures LLM API traffic raw; the wrapper decodes it by request URL so the client sees real streamed assistant output:

URL pattern Decoder Used by
*/chat/completions OpenAI Chat Completions opencode, pi, Fireworks, OpenRouter, …
*/v1/messages (api.anthropic.com) Anthropic Messages claude
*/v1/responses OpenAI Responses codex

Unknown URLs degrade to the agent_output fallback rather than crashing.

Protocol notes

The implementation follows the official ACP schema (via the SDK); a few points worth knowing where it differs from a naive reading of the spec:

  • protocolVersion is an integer (u16); the agent echoes the client's integer back (legacy date strings are coerced to 1 on input).
  • sessionId in session/new is generated by the agent, not supplied by the client.
  • PromptResponse has no message field — final assistant text is streamed incrementally via agent_message_chunk updates; a fallback chunk ("Spens session finished…") is sent only when no text was decoded.
  • stopReason has no "error" value — a spens session ending in the error state fails session/prompt with a JSON-RPC error (-32603).
  • Error codes -32000 / -32002 are reserved by ACP (authentication / resource-not-found) and clients discard the agent's text for them, so spens-acp uses -32603 / -32602 for its own errors.
  • No "cancelled" tool-call status exists — the framing spens tool call is closed with failed when a prompt is cancelled; overall cancellation is signalled via stopReason: "cancelled".
  • spens env / agent selection travels in session/new params — either as top-level env / agent keys (spens-native clients) or in the official _meta extensibility field (official-SDK clients); both converge on the same handler.

stdout discipline

Only JSON-RPC ever goes to stdout (the SDK owns the stdio transport). All wrapper logging, spens subprocess stdout/stderr, and decoder warnings go to stderr. Key milestones (launch confirmed, session ended) are always logged to stderr; set SPENS_DEBUG=1 for argv / event / state-transition detail.

Project layout

spens_acp/
  __init__.py
  __main__.py          # python -m spens_acp entrypoint
  main.py              # env/flags → acp.run_agent() + signal handling + --smoke-test
  acp_server.py        # SpensAgent: acp.Agent implementation + session lifecycle
  event_mapper.py      # events.jsonl → internal updates (spec §7.3)
  history.py           # Per-session turn history → replayed transcript prefix
  persist.py           # Session state → <spens-dir>/acp-sessions/ (for session/resume)
  launcher.py          # argv builder + subprocess spawn + cancel
  watcher.py           # Polling JSONL tailer (partial-line safe, resume by offset)
  decoder/
    __init__.py          # URL-based dispatch
    chatcompletions.py   # OpenAI Chat Completions (golden-verified)
    anthropic.py         # Anthropic Messages (spec-built + synthetic tests)
    responses.py         # OpenAI Responses (spec-built + synthetic tests)
    common.py            # Tool kind mapping, result dedup, truncation
  session_map.py       # ACP sessionId ↔ spens session id
  config.py            # Env/flag/workspace-config resolution
  types.py             # Internal update dataclasses + SDK conversion + prompt flattening

tests/
  test_decoder_chatcompletions.py  # Unit tests + golden fixture from example-data
  test_decoder_anthropic.py        # Synthetic SSE events
  test_decoder_responses.py        # Synthetic events
  test_event_mapping_golden.py     # Real example-data events.jsonl → ACP updates
  test_history.py                 # History replay + truncation budgets
  test_persist.py                 # Resume state files: paths, sanitize, round-trip
  test_fake_spens_e2e.py           # Full prompt + cancel + error lifecycle (no Docker)
  test_protocol_e2e.py             # Wire test: SDK client ↔ agent subprocess over stdio
  test_acp_server.py               # SpensAgent handlers + event mapping
  test_config.py                   # Resolution + argv builder
  test_session_map.py              # ID sanitize, collision, truncation
  test_prompt_flattening.py        # SDK blocks + legacy dicts, image rejection
  test_tool_kind.py                # §7.5 mapping table
  test_truncation.py               # SPENS_TOOL_RESULT_MAX
  test_watcher.py                  # Partial lines, growing files, late creation

Troubleshooting

First, split the problem in half — run one real prompt through the whole pipeline without any editor:

spens-acp --smoke-test "reply with hello" --env python-3.12 --agent pi

If the smoke test passes but your editor hangs, the problem is between the editor and the wrapper (see the log decision tree below); if the smoke test hangs or fails, its last line shows exactly where the wrapper stopped, with the session dir it was waiting for.

Log decision tree — every line below goes to stderr (SPENS_DEBUG=1 adds argv / event / state detail). In Zed, agent stderr lands in the Zed log (zed: open log):

  1. spens-acp 0.1.x starting; spens binary: … — no line at all means an old build is installed or the agent never started
  2. session '…' created (cwd=…, env=…, agent=…) — session/new OK
  3. prompt received for session '…' — the prompt request arrived
  4. launching spens session '…' in <workspace> — right before spawn
  5. spens session '…' confirmed via … — launch confirmed (session dir on disk, or the id on stdout); stuck before this line means the session dir never appeared where expected (the SPENS_DEBUG line expecting session dir: … shows the exact path — compare it with where spens actually wrote the session)
  6. spens session '…' ended: state='…' -> … — terminal state reached

Prompt hangs forever — launch no longer waits for spens' pipes to close (detached session children can hold them open after the CLI exits) and no longer depends on spens' stdout at all (a piped Python CLI block-buffers stdout, so the session-id line can be trapped until exit — or lost entirely if spens daemonizes with os._exit()). Launch is confirmed by the session directory appearing on disk, both pipes are drained for the launcher's lifetime so a foreground spens can never freeze on a full pipe, and if nothing confirms within SPENS_LAUNCH_TIMEOUT the launcher is killed with a clear error. If the session directory never appears where the wrapper expects it (<workspace>/.spens/sessions/<id>/, or SPENS_DIR), the prompt fails after SPENS_SESSION_DIR_TIMEOUT with the exact path it was waiting for.

Running tests

# All 99 tests. The ACP SDK must be importable; if it is not installed
# into the environment (e.g. wheels were extracted manually), point
# PYTHONPATH at it:
PYTHONPATH=.local-packages/site-packages python -m unittest discover -s tests -v

test_protocol_e2e.py spawns the real agent entrypoint as a subprocess with a fake spens executable and drives it through the SDK's ClientSideConnection — the same path a real editor client takes (POSIX-only; skipped on Windows).

Golden tests use the real example-data/ session capture shipped with this repo (OpenAI Chat Completions). Synthetic fixtures cover Anthropic Messages and OpenAI Responses until real captures are available.

License

Same as spens.

Release files for spens-acp 1.0.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for spens-acp 1.0.1
File Size Uploaded
spens_acp-1.0.1.tar.gz 9.6 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for spens-acp 1.0.1
File Interpreter ABI Platform
spens_acp-1.0.1-py3-none-any.whl Python 3 none any Details

Total release size: 9.7 MB

Release files / spens_acp-1.0.1.tar.gz

Download URL spens_acp-1.0.1.tar.gz
Size 9.6 MB
Tags Source
SHA-256 checksum
How to use checksums
ba80d7419d1640f2a99ab6ef9db2f822ff035fa0de916f31d9b20f9b0e8ecfa2
BLAKE2b-256 checksum
How to use checksums
9ead3f9f1ae8f841e49eaae9da3b227093ba1eef0ebf9634d90eca00eea13171
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / spens_acp-1.0.1-py3-none-any.whl

Download URL spens_acp-1.0.1-py3-none-any.whl
Size 45.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2df16ada7e45e0d4bdf01c95e3adc4dd9383ed60f96a114b9d94c9cb64c9a06c
BLAKE2b-256 checksum
How to use checksums
b33e403f0c3d9ffbf5c2a94c99f6a7f5b3ead1d728dceb523087e0d667ccdd0e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

1.0.3

2 release files

1.0.2

2 release files

This release

1.0.1 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page