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/providersand 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)
| File | Size | Uploaded | |
|---|---|---|---|
| tapebox-0.1.0.tar.gz | 86.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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}
|