Skip to main content

TapeBox

Record what an agent does in a sandbox as a tape, then play the tape back in a fresh sandbox without calling a model.

An agent run is a conversation with a model plus the tool calls the harness ran between model calls. TapeBox records the conversation by proxying the model calls, and replays it by playing both sides of the harness: the model (serving recorded responses) and the harness (running the tool calls those responses contain). The sandbox is the only thing that runs for real, which is what makes tapes a repeatable workload for container runtimes, microVMs, schedulers and snapshot/fork mechanisms.

Components

                 record                                   replay
                 ------                                   ------
  sandbox     ┌───────────┐                         ┌───────────────┐
              │  agent    │                         │ tapebox-exec  │  plays the harness:
              │ (harness) │                         │  + harness/   │  tool call -> run -> result
              └─────┬─────┘                         └───────▲───────┘
  model calls       │                      docker exec -i,  │ JSON lines on stdin/stdout
                    ▼                                       │
  host        ┌───────────┐   tape   ┌─────┐   tape   ┌─────┴─────────┐
              │  gateway  │ ───────> │ api │ <─────── │  controller   │  plays the model: walks the
              │  (proxy)  │          └─────┘          │               │  tape, issues its tool calls
              └─────┬─────┘                           └───────────────┘
                    ▼                                   ▲ or speaks the provider API, so a real
              real provider                             │ harness can replay (simulator mode)
Component Where it runs Job
tapebox/api host the tape format, the controller/executor protocol, provider wire-format adapters, the recorder. Stdlib only.
tapebox/gateway host proxy between the agent and the provider; records every call with timing into one tape file
tapebox/telemetry host samples the container's CPU and memory through the Docker API, 4 times a second, into a CSV next to the tape (--telemetry)
tapebox/controller host replays a tape: drives tapebox-exec in the sandbox through one exec session (executor mode), or answers a real harness's model calls (simulator mode); optionally records the replay as a new tape
executor/ sandbox tapebox-exec, a 2 MB static Rust binary on the sandbox PATH, built for x86_64 and aarch64 and bundled in the wheel. Performs tool calls the way the harness would and formats their results likewise. Needs no Python, libc, or network.
executor/src/harness sandbox one module per harness (Claude Code, Codex): tool call -> action, action result -> tool result, plus the policy (concurrency, subagent tools) the binary reports to the controller

Two separations keep the code reusable:

  • Provider vs. harness. A provider is an API shape (Anthropic Messages, OpenAI Chat Completions and Responses); a harness is the program that used it (Claude Code, Codex). Provider adapters live in tapebox/api/providers and are shared by gateway and controller. Harness modules live in the executor binary and know nothing about bytes on the wire; the controller learns the little it needs about a harness (which calls run concurrently, which spawn subagents) from the binary's hello reply.
  • Normalized vs. raw. A tape stores each model call in a provider-independent form (what the executor and analysis read) and in the provider's raw form (what the controller streams to a real harness in simulator mode). Normalized is always derived from raw.

A tape is one plain JSON object in one file, so a collection of runs can go straight into a Hugging Face dataset, one tape per row. The file is rewritten after every model call, so a run that is still going, or that was cut short, is a readable tape.

Install

pip install tapebox[host]        # host: gateway, controller, and the bundled executor binaries

The wheel carries tapebox-exec as static binaries for both Linux architectures, under tapebox/bin/. The sandbox needs nothing installed, not even Python (Claude Code's Bash tool does need /bin/bash). Get the binary onto the sandbox's PATH either way:

tapebox replay ... --sandbox docker:mybox --install-executor     # copies it into the container
docker cp "$(tapebox exec-binary --arch aarch64)" mybox:/usr/local/bin/tapebox-exec   # by hand

From a checkout:

uv pip install -e ".[host,dev]"  # editable: no binary build
executor/build.sh                # -> executor/dist/tapebox-exec-{x86_64,aarch64} and tapebox-exec (host arch)

executor/build.sh compiles in a Docker container (rust:1-alpine plus both musl targets), so the host needs Docker but no Rust toolchain. Both architectures are cross-compiled from either kind of host; the crate has no C code, so Rust's own linker does the foreign target. An editable install finds the binaries in executor/dist/; uv build runs the same script and bundles the result into the wheel (hatch_build.py), so a published package always carries freshly built binaries. The binaries are never committed.

Usage

# record: run the agent with its base URL pointed at the gateway; one run, one JSON file
tapebox gateway --output run.json --listen :8080 --task bench=tb4 --task id=hello-world
ANTHROPIC_BASE_URL=http://host:8080 claude -p "..."
curl -X POST http://host:8080/tapebox/end        # or just stop the gateway

# replay, executor mode: no harness and no model; tapebox-exec is on the container's PATH
tapebox replay --tape run.json --sandbox docker:mybox --cwd /app --output replay.json

# replay, simulator mode: the real harness, no model
tapebox replay --tape run.json --listen :8081 --output replay.json
ANTHROPIC_BASE_URL=http://host:8081 claude -p "..."         # inside the sandbox

tapebox inspect run.json
tapebox compare run.json replay.json

Executor mode runs to completion and exits non-zero if some thread of the tape could not be played to its end. The controller starts tapebox-exec serve once per sandbox (docker exec -i) and keeps that session for the whole replay, so a tool call costs a pipe round trip rather than a process start: about 0.2 ms instead of the 100 ms of a docker exec per call. Several tool calls may be in flight at once, which is how parallel tool calls replay in parallel. If the executor dies, the session restarts it; background commands keep their output on the sandbox's disk, so a restarted executor still answers for them.

Simulator mode serves the provider's own paths (/v1/messages, /v1/chat/completions, /v1/responses). Several harnesses can replay the same tape concurrently through /r/<run>/v1/...; each run's replay tape goes to <stem>.<run>.json.

Pacing: the controller reproduces the model's recorded latency, chunk by chunk for streams. --speed 2 halves it, --speed 0 removes it. Tool execution itself always runs as fast as the sandbox allows.

Identifiers: a harness names sessions, background commands and subagents with fresh random ids and tells the model about them in tool results. The controller compares each tool result the replay reports with the recorded one, learns which new id stands for which recorded one, and substitutes them in the responses it serves from then on.

Telemetry

--telemetry samples the sandbox container's CPU and memory every 250 ms through the Docker Engine API (/containers/<id>/stats, one-shot) and writes them as CSV next to the tape, as <output stem>.telemetry.csv:

tapebox gateway --output run.json --telemetry --container mybox             # while recording
tapebox replay --tape run.json --sandbox docker:mybox --output replay.json --telemetry   # while replaying
tapebox replay --tape run.json --listen :8081 --output replay.json --telemetry --container mybox
column meaning
t_ms milliseconds on the tape's clock, so rows line up with the steps' timing
time the sample's wall-clock time, UTC
cpu_percent as docker stats: 100 means one core busy since the previous sample; empty on the first row
cpu_ms cumulative container CPU time
memory_bytes memory in use, cgroup usage minus inactive file cache, as docker stats shows it
memory_limit_bytes the container's limit
pids processes in the container

The Docker endpoint is DOCKER_HOST, else the active docker context, else /var/run/docker.sock. A missing container fails at start-up; transient API errors are logged and sampling continues.

Where tapes are written

Both recording and replay write a tape to the path given with --output; nothing is written anywhere else. Parent directories are created; an existing file is overwritten.

Command --output What the file holds
tapebox gateway --output run.json required the recording: every model call of the run, with timing
tapebox replay --tape run.json --output replay.json optional the replay, in the same format: the responses served and the tool results the sandbox actually produced, with the replay's own timing and meta.replay_of set to the recording's id

A replay tape is a tape like any other: it can be replayed again, inspected, or put in the dataset next to its recording. tapebox compare run.json replay.json diffs the two step by step and exits non-zero when the replay did not get to the end.

The executor protocol

tapebox-exec serve reads requests on stdin and writes results on stdout, one JSON object per line. Logs go to stderr; the process's own stdout is redirected to stderr at start so nothing a tool prints can corrupt the channel.

controller -> executor                                  executor -> controller
{"op":"hello","harness":"claude-code","cwd":"/app",     {"op":"hello","ok":true,"version":"0.1.0","protocol":1,
 "env":{},"state_dir":null}                              "harness":"claude-code","cwd":"/app",
                                                         "policy":{"parallel_tool_calls":true,
                                                                   "spawning_tools":["Agent","Task"]},
                                                         "platform":"linux-6.8.0-x86_64"}
{"op":"call","id":"7","call":{"id":"toolu_01",          {"op":"result","id":"7","result":{"tool_call_id":"toolu_01",
 "name":"Bash","input":{"command":"ls"}},                "content":[{"type":"text","text":"..."}],
 "recorded":{...} | null}                                "is_error":false,"duration_ms":12}}
{"op":"bye"}                                            {"op":"bye","ok":true}
                                                        {"op":"error","id":"7"|null,"message":"..."}

hello comes first and sets up the sandbox (harness module, working directory, environment). Its reply carries the harness's policy: whether the tool calls of one response run concurrently, and which tools spawn a subagent. Spawning calls never reach the executor; the controller plays the child thread and hands its final text back as the result. Any number of call requests may be in flight; results come back in completion order and are matched by id. recorded is the tape's result for the call, used for tools whose effect is not in the sandbox (replayed verbatim). call and result carry the tape's own ToolCall and ToolResult records. tapebox-exec call --harness NAME --cwd DIR runs a single call request from stdin without a session, for debugging. The full definition is the docstring of tapebox/api/protocol.py.

Background commands (run_in_background) are detached with their output, pid and exit code written under <cwd>/.tapebox/tasks/, so a restarted executor still answers for them.

The tape

tape
  version        1
  meta           tape_id, harness, harness_version, provider, model, started_at, task{}, replay_of, labels{}
  threads[]      one per conversation: the main agent, then each subagent
    id, parent   parent = [thread id, tool call id] that spawned it, null for the main thread
    steps[]      one per model call
      request    system, messages[], tools[], params{}, stream, raw{}, headers{}, path
      response   content[] (text | thinking | tool_use), stop_reason, usage{}, raw{}, chunks[] (at_ms, data), status
      tool_calls[]    id, name, input{}           parsed from the response
      tool_results[]  tool_call_id, content[], is_error, duration_ms   taken from the thread's next request
      timing     request_at, first_byte_at, last_byte_at, next_request_at   (ms since meta.started_at)

tapebox/api/tape.py is the source of truth, tapebox/api/schema.json the JSON Schema.

Adding a harness

Write one module under executor/src/harness/ implementing the Harness trait (policy, to_action, to_result), add it to get in executor/src/harness/mod.rs, rebuild, and record tapes with --harness <name>. The actions a harness can map to are in executor/src/actions.rs: Shell, TaskOutput, ReadFile, WriteFile, EditFile, Glob, Grep, ApplyPatch, Noop. Tools with no sandbox effect (todo lists, web fetches, MCP tools) map to Noop with the recorded result replayed verbatim. Nothing on the host changes.

Layout

tapebox/                                              the host side (Python)
  api/            tape.py  io.py  recorder.py  protocol.py  schema.json  providers/{base,anthropic,openai}.py
  gateway/        server.py  upstream.py
  telemetry.py    CPU and memory of the container every 250 ms, as CSV next to the tape
  controller/     driver.py  sandbox.py  session.py  simulator.py  server.py  cursor.py  matcher.py  pacing.py  identifiers.py  compare.py
  cli.py                                              (tapebox)
executor/                                             the sandbox side (Rust, static binaries)
  src/            main.rs  protocol.rs  actions.rs
    harness/      mod.rs  claude_code.rs  codex.rs
  build.sh  Dockerfile.build  Cargo.toml              -> dist/tapebox-exec-{x86_64,aarch64}
hatch_build.py                                        bundles them into the wheel as tapebox/bin/
tests/

Tests

uv pip install -e ".[host,dev]"
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest

The tests drive the real tapebox-exec binary and build both architectures first if they are missing or stale (so they need Docker). The end-to-end test records a scripted run (a fake provider, a mini harness that runs its Bash calls for real and spawns a subagent) through the gateway, then replays the tape in executor mode (locally, and in a debian:bookworm-slim container with no Python in it when that image is available) and in simulator mode, and checks that the sandbox ends up in the same state and the replay tapes compare identical.

License

Apache 2.0.

Metadata

Release files for tapebox 0.1.0

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

Source distribution (sdist)

Source distribution for tapebox 0.1.0
File Size Uploaded
tapebox-0.1.0.tar.gz 86.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tapebox 0.1.0
File Interpreter ABI Platform
tapebox-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.9 MB

Release files / tapebox-0.1.0.tar.gz

Download URL tapebox-0.1.0.tar.gz
Size 86.3 kB
Tags Source
SHA-256 checksum
How to use checksums
58db3f49ef6d7a0692d8678076a02eafcaf980f004b991e6ae99b4d997b35e25
BLAKE2b-256 checksum
How to use checksums
df9cb8c58e70ee0e113e9561cd4830a128e5b0bf9a473dc59266c96d398f90fb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.22 {"installer":{"name":"uv","version":"0.12.22","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / tapebox-0.1.0-py3-none-any.whl

Download URL tapebox-0.1.0-py3-none-any.whl
Size 1.8 MB
Tags Python 3
SHA-256 checksum
How to use checksums
1f064a6374652f9dcd028912c79a79539452f864ccf3bd4f06ab89107b4d814d
BLAKE2b-256 checksum
How to use checksums
72cb336b97e26a4c177eec0a6a535fddca37d1deba20218bbee8e6c97278a4d4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.22 {"installer":{"name":"uv","version":"0.12.22","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

0.1.0 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