tbcore / docs / runbook
Run an agent experiment with tbcore
When to use this
You want to run a baseline against a candidate (two prompts, two models, two agent builds) on a Docker host you control, and keep one record per run that explains the result. Or a run already broke and you need it back. If all you need is a single model call, use the provider's SDK instead; tbcore is for when the run, its cost, and the comparison need to live together.
On this page
Fastest path: the offline fixture #
About five minutes on a warm Docker cache. No API key, no model calls, nothing billed. Run from the repository root.
uv sync
uv run python examples/experiment_workflow.py --store .tbcore/example
It prints a small JSON object. candidate_success_rate should be 1.0, and candidate_cost_denominator is 0 because the fixture makes no model calls. A zero there means "nothing metered", not "free". If this works, your host is fine and you can skip to step 5.
Prepare the host #
Steps 1 to 4. Once per machine, and again after upgrading tbcore.
-
Check prerequisites #
You need uv, Python 3.11.4 or newer (the trial executor copies results out with
tarfile's extraction filters, which arrived in 3.11.4; uv downloads one if none is installed), and a running Docker daemon at 20.10 or newer. The proxy starts with--pull=never, which older engines reject.shelluv --version docker version --format '{{.Server.Version}}'ExpectTwo version numbers. An error mentioning
docker APIordockerDesktopLinuxEnginemeans the daemon is not running: start Docker Desktop, orsudo systemctl start dockeron Linux. -
Install with uv #
Install from a checkout so the bundled examples and scripts are on hand;
uv add tbcoreworks too if you only need the library.uv synccreates.venvand installs tbcore in editable mode. Thedevextra brings pytest, ruff, and mypy; drop--extra devif you only run experiments.shelluv sync --extra dev uv run python --version uv run python -c "import tbcore; print(tbcore.__version__)"ExpectA Python version of 3.11.4 or newer, then
0.2.0. Every later command in this runbook goes throughuv run, which uses this environment. -
Build the sandbox and proxy images #
The build contexts ship inside the package. The proxy image is the only container that ever sees your API keys, so tbcore will not pull it from a registry: it has to exist locally.
shelluv run python -c "import tbcore; print(tbcore.build_default_images())" docker image ls tbcore/*Expect
{'sandbox': 'tbcore/sandbox:latest', 'proxy': 'tbcore/proxy:latest'}, then both images in the listing.Note
Upgrading tbcore does not rebuild images or touch running containers. Re-run this step after every upgrade, and record the new image IDs before switching workloads over. An old proxy image is refused with a "speaks protocol" error, so you can't forget for long.
On a released version
Skip the build and pull the published, digest-pinned images instead:
images = tbcore.pull_default_images(), then passimages["sandbox"]andproxy_image=images["proxy"]toSandbox. Unreleased checkouts and offline hosts keep building. -
Prove the host works with the fixture #
The example runs the same tiny task twice (baseline writes
before, candidate writesafter), grades both, exports the candidate, and replays it in a fresh store. It reuses the image you just built.shelluv run python examples/experiment_workflow.py --store .tbcore/example --image tbcore/sandbox:latestExpectThree run IDs, plus
.tbcore/example/comparison.md,.tbcore/example/candidate-bundle.json, and areplay/store next to them. Quality reads 0/1 for baseline and 1/1 for candidate. One trial each is a plumbing check, not evidence about anything.
Run an experiment #
Steps 5 to 13. The Python snippets build one script, experiment.py, top to bottom.
The running example: an agent gets a small repo with a broken parser.py and has to fix it. Baseline and candidate differ only in the system prompt. Each runs five seeds. A grader image runs the hidden tests against the returned parser.py.
-
Build your agent image #
Trials run with no general network access, so
pip installinside the container will fail. Bake the agent's dependencies into an image built on the tbcore sandbox. Pin every version inrequirements.txt; a floating SDK version is a changed environment between runs.agent-image/DockerfileFROM tbcore/sandbox:latest USER root COPY requirements.txt /tmp/requirements.txt RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt USER sandboxshelldocker build -t parser-agent:2026-10-15 agent-image/ docker image inspect --format '{{.Id}}' parser-agent:2026-10-15ExpectA
sha256:...image ID. Tag with a date or version, neverlatest, so a rebuild can't silently change what a rerun uses. -
Open a store with a capture policy #
The default policy keeps metadata only: IDs, hashes, timings, exit codes, token counts. You need content capture to replay inputs later or to keep the agent's output files, so most experiments turn it on. List every secret you know about in
secret_values; matching text is redacted, and a captured workspace containing one fails loudly.Before you enable content capture
Source files, outputs, and tool results will sit on disk under the store root and inside every exported bundle. Redaction only catches the exact values you list; it cannot find a secret it was never told about.
python · experiment.pyimport json import os import subprocess from pathlib import Path import tbcore from tbcore import CapturePolicy, ExperimentStore API_KEY = os.environ["ANTHROPIC_API_KEY"] # host only; never passed to the sandbox ROOT = Path(".tbcore/parser-exp") store = ExperimentStore( ROOT, CapturePolicy(content_enabled=True, secret_values=(API_KEY,)), )Expect
.tbcore/parser-exp/experiments.sqlite3appears. Use one store root per experiment and one writing process per root. -
Freeze the inputs and the environment #
A Git commit isn't enough when the task directory is dirty, so tbcore snapshots the files themselves, untracked ones included.
.git,.venv, and.agentare skipped by default; symlinks are rejected rather than followed. Then record what the run will execute on.python · experiment.pyfrom tbcore import EnvironmentProvenance snapshot = store.capture_workspace("tasks/fix-parser", exclude=("*.log",)) IMAGE = "parser-agent:2026-10-15" image_id = subprocess.run( ["docker", "image", "inspect", "--format", "{{.Id}}", IMAGE], capture_output=True, text=True, check=True, ).stdout.strip() env_ref = store.record_environment( EnvironmentProvenance( image_id=image_id, registry_digest=None, tbcore_version=tbcore.__version__, agent_cli_version="parser-agent-0.3.1", model="claude-sonnet-5-5", provider="anthropic", resource_limits={"memory": "512m", "cpus": "1.0"}, runtime="runc", policy_config={"network": "proxy-only"}, ) )Expect
snapshot.snapshot_idandenv_refare plain strings. Binary files in the task directory needExperimentStore(..., allow_binary_workspaces=True), because binary bytes can't be redacted. -
Define the trials #
One
TrialCaseper task, configuration, and seed. Seeds are your statistical samples. Retries are not: a retry is another attempt at the same trial and never counts twice.python · experiment.pyfrom tbcore import TrialCase CONFIGS = { "baseline": {"system_prompt": "prompts/v1.txt"}, "candidate": {"system_prompt": "prompts/v2.txt"}, } cases = [ TrialCase( "fix-parser", name, snapshot.snapshot_id, config, seed=seed, environment_ref=env_ref, dataset_version="parser-v2", ) for name, config in CONFIGS.items() for seed in range(1, 6) ]ExpectTen cases. If you edit a configuration later, also change the experiment ID in step 9. Under the same ID, old and new runs share a group and the comparison would count both.
-
Run the trials in the sandbox #
Each attempt gets a fresh copy of the snapshot. The wrapper drops the trial's configuration in as
config.jsonfor the agent to read. Withinject_headers, the API key stays in the proxy container: the agent seesANTHROPIC_BASE_URLpointing at the proxy and a placeholder key, and the proxy reports token usage per attempt.This step spends money
Every model call is billed to the key's account.
proxy_budgetapplies per attempt and is an admission limit: calls already in flight can push past it. Ten attempts at 20 calls each is a ceiling of 200 calls, not 20.python · experiment.pyfrom tbcore import SandboxTrialExecutor, TrialRunner executor = SandboxTrialExecutor( store, IMAGE, ("python", "agent.py"), inject_headers={"api.anthropic.com": {"x-api-key": API_KEY}}, proxy_budget={"max_model_calls": 20, "max_total_tokens": 60_000}, ) def execute(case, workspace, attempt_id, context): (workspace / "config.json").write_text(json.dumps(case.configuration), encoding="utf-8") executor(case, workspace, attempt_id, context) runs = TrialRunner( store, "parser-prompt", execute, output_paths=("parser.py",), max_workers=2, max_attempts=2, ).run(cases) for run in runs: print(run.run_id, run.status)ExpectTen lines ending in
completed. Anything else is visible on purpose:failed,timed_out,cancelled, andinterruptedattempts stay in the store under their trial. The agent command has 60 seconds per attempt (the sandbox default); a slower agent needs its own executor with a longerSandbox(timeout_s=...). -
Grade the outputs #
Grading reads stored artifacts, so you can re-grade without rerunning the agent. A command grader runs in its own read-only container with no network and no keys; the run's artifacts are copied into
/work, and exit code 0 means pass. Keep the grading code in the grader image, outside anything the agent could write.python · experiment.pyfrom tbcore import EvaluationSpec, Evaluator GRADER = EvaluationSpec( "parser-tests", "3", command=("python", "/grader/run_tests.py"), image="parser-grader:2026-10-15", ) grader = Evaluator(store) for run in runs: result = grader.evaluate(run.run_id, GRADER) print(run.run_id, result.verdict)Expect
passorfailper run.errormeans the grader itself broke (timeout, missing image, missing artifact); that is kept separate from the agent's own status. For exact-match checks useEvaluationSpec(..., expected_text={"answer.txt": "42\n"}), which needs no image. -
Price the runs #
Prices come from a card you write and date, so an estimate always says where its numbers came from. Rates are USD per million tokens. Copy them from the provider's price list on the day you run, not from this page.
python · experiment.pyfrom tbcore import CostLedger, PriceCard, TokenRates CARD = PriceCard( version="anthropic-2026-10-15", source="Anthropic price list, read 2026-10-15", effective_date="2026-10-15", currency="USD", rates={"claude-sonnet-5-5": TokenRates(input="2", output="10", cache_read="0.10", cache_write="2.50")}, ) ledger = CostLedger(store) for run in runs: ledger.price_run(run.run_id, CARD) totals = ledger.run_totals(run.run_id) print(run.run_id, totals.known_subtotal, totals.unknown_calls, totals.total)Expect
unknown_callsof 0 and a numerictotal. IftotalisNone, at least one call had missing usage or a model ID that isn't inrates. That's deliberate: tbcore will not turn an unknown into zero. New prices later? Callprice_run(..., recalculate=True)with a new card; the original estimate stays. -
Compare baseline and candidate #
The comparison takes one attempt per trial, pairs trials by task and seed, and refuses to mix dataset or evaluator versions.
python · experiment.pyfrom tbcore import compare_experiments report = compare_experiments( store, "parser-prompt", "baseline", "candidate", "parser-v2", "parser-tests", "3", ) (ROOT / "comparison.md").write_text(report.to_markdown(), encoding="utf-8") (ROOT / "comparison.json").write_text(report.to_json(), encoding="utf-8")Read the denominators before the rates. Quality, latency, tokens, and cost each have their own, and anything excluded is counted. The success interval is a Wilson 95% interval; the paired difference is an effect estimate, not a significance test. With five seeds a side the interval will be wide. That's the honest answer, not a bug.
Expect
comparison.mdshowing 5 trials per side. Fewer means some trials failed execution or grading; the report says which. -
Export, close, and check for leftovers #
A bundle carries one run with everything needed to replay it elsewhere: spec, events, artifacts, workspace blobs, and environment provenance. Close the store before you copy or back it up.
python · experiment.pybest = runs[-1] bundle = store.export_run(best.run_id) (ROOT / f"{best.run_id}.bundle.json").write_text(json.dumps(bundle, indent=2), encoding="utf-8") store.close()shelldocker ps -a --filter name=tbcore- --format '{{.Names}} {{.Status}}' docker network ls --filter label=owner=tbcoreExpectBoth listings empty. Leftovers mean a process died mid-run; see R5. Treat the bundle like a backup of the store: it holds whatever content you captured.
Verify the result #
Tick these before you quote a number from the comparison to anyone.
Recover #
Short procedures for the things that go wrong between runs. Each one is safe to follow from the top.
R1. Resume after a crash or Ctrl-C #
Run the same script again with the same store root and experiment ID. Completed trials are returned, not rerun. Attempts left in running by a dead process are marked interrupted, and their replacements get new attempt IDs. Registered containers from the dead process are removed before the retry.
uv run python experiment.py
If it raises TrialOwnedError, another live process still owns a trial. Stop that process; tbcore never steals a claim from a live owner. Re-running repeats any external side effects of the interrupted attempt, including model calls.
R2. Back up a store #
Stop every writer and call store.close() first. Copy the whole root: the SQLite file, any WAL files, artifacts/, workspaces/, and environments. A copy of the database alone produces missing-artifact errors later.
mkdir -p ~/tbcore-backups
cp -a .tbcore/parser-exp ~/tbcore-backups/parser-exp-$(date +%Y%m%d-%H%M)New-Item -ItemType Directory -Force "$HOME\tbcore-backups" | Out-Null
Copy-Item -Recurse .tbcore\parser-exp "$HOME\tbcore-backups\parser-exp-$(Get-Date -Format yyyyMMdd-HHmm)"
Keep backups private. With content capture on, they hold everything the redaction list didn't recognise.
R3. Restore a store #
- Copy the backup into a new directory. Don't overwrite the damaged one; you may want it for diagnosis.
- Point
ROOTat the new directory and open it with the same tbcore version that wrote it. - Opening checks every recorded artifact hash. A clean open means the restore is good. Then resume as in R1.
On open, the store also deletes artifact files that no run references (leftovers from a write that crashed before its metadata commit). It never deletes a referenced file; missing or corrupt referenced files raise instead.
R4. Upgrade tbcore, or roll back #
The store schema is SQLite user_version=1. A newer tbcore may add tables to an old store; an older tbcore refuses a newer store with unsupported store schema version. There is no automatic downgrade.
- Back up the store (R2) and note the current package version and image IDs.
- Upgrade the package, rebuild images (step 3), and try the upgrade on a copy of the store first.
- To roll back: stop writers, reinstall the previous package, restore the matching backup (R3), and keep using the old image IDs.
R5. Remove orphan containers and networks #
TrialRunner cleans up containers it registered. Anything else named tbcore- was left by a Sandbox whose process died (a kill -9, a laptop lid) and is safe to inspect first.
docker ps -a --filter name=tbcore- --format '{{.Names}} {{.Status}} {{.CreatedAt}}'
docker network ls --filter label=owner=tbcore
Destructive
The next commands kill containers and delete their volumes. Anything inside that wasn't copied out is gone. Make sure no experiment is running on this host, and remove containers one name at a time from the list above.
docker rm -f -v tbcore-sess-<id>
docker network rm tbcore-net-<id>
R6. Replay a run on another host #
Import the bundle into a fresh store and run the same case with a new seed. Hashes are checked before anything is inserted. The agent image must exist on that host with the same ID; build it from the same Dockerfile and pinned requirements, then compare the ID with the bundle's environment record.
import json
from pathlib import Path
from tbcore import CapturePolicy, ExperimentStore
# the file written in step 13, copied over from the original host
bundle = json.loads(Path("RUN_ID.bundle.json").read_text(encoding="utf-8"))
replay = ExperimentStore(".tbcore/parser-replay", CapturePolicy(content_enabled=True))
replay.import_bundle(bundle)
A store captured metadata-only can't be replayed this way: it knows the input fingerprints but doesn't have the files. Replaying reproduces inputs, not model output.
Troubleshoot #
Search this table for the first line of the error.
| Symptom | Likely cause | Fix |
|---|---|---|
failed to start proxy container 'tbcore/proxy:latest' with "No such image" | The proxy image was never built here. tbcore deliberately won't pull it. | Run step 3. |
proxy image ... speaks protocol unknown (or another number) | The local proxy image was built by an older or newer tbcore. | Rebuild with step 3, or on a released version use pull_default_images(). |
failed to connect to the docker API | Docker daemon isn't running. | Start Docker Desktop, or sudo systemctl start docker. |
proxy did not become ready in time | Slow host, or the proxy is crash-looping. | Re-run once. If it repeats, start a Sandbox by hand and read docker logs for the tbcore-proxy- container before it's removed. |
proxy container died: followed by a traceback | The proxy rejected its config, usually proxy_budget. | Only max_model_calls, max_total_tokens (integers), max_cost_usd, and rates (decimal strings) are accepted. |
TimeoutError: sandbox agent command timed out | The agent ran past the 60 second sandbox default. | Write a custom executor with Sandbox(timeout_s=...), or make the agent faster. |
RuntimeError: sandbox agent command exited 1 | The agent itself failed inside the container. | Check the command event in the run's timeline. Common culprits: a dependency missing from the image, or the agent trying to reach a host that isn't allowed. |
BudgetExceeded | A run hit its wall-clock, call, token, or cost limit. | Working as designed. Raise the limit on purpose, or make the agent cheaper. Unpriced models are refused outright when a spend limit is set. |
TrialOwnedError | Another live process owns the trial. | Stop the other runner. One writing process per store root. |
unsupported store schema version: 2 | The store was written by a newer tbcore. | Install that version, or restore an older backup (R4). |
workspace contains a symlink | Symlinks are rejected so a trial can't point outside its workspace. | Replace the link with a real file in the task directory. |
artifact size exceeds policy limit | An output is over max_artifact_bytes (1 MiB by default). | Raise CapturePolicy(max_artifact_bytes=...), or capture a smaller output. |
content capture is disabled | Metadata-only policy, but something tried to store content. | Set CapturePolicy(content_enabled=True) if storing content is approved. |
copy_out needs Python >= 3.11.4 | Old Python patch release. | Recreate the environment on a newer Python: uv sync --python 3.12. |
path must not contain '..' and similar | An input file name tried to escape /work. | Use relative names below /work, with forward slashes. |
Cost total is None | A call had no usage, or its model ID isn't in the price card. | Add the exact model ID to rates. Calls with missing usage stay unknown by design. |
| Container fails with permission errors at start | The image's entrypoint needs root (chown, gosu) and tbcore drops all capabilities. | Remove root-only startup steps from the image and pass user="1000:1000". |
What tbcore promises, and what it doesn't #
Background for when you have time. These are the places people over-read the results.
| It says | What that actually means |
|---|---|
| Repeatable | Inputs, image ID, versions, and settings are recorded and can be restored. Model sampling and external tools are not deterministic, even with a fixed seed. |
| Budget limits | Admission checks before each call. Calls already in flight can overshoot. Host-side and proxy budgets keep separate counters, so there's no shared ceiling across both paths. |
| Cost | An estimate from your dated price card. Not an invoice. Unknown usage stays unknown and is never counted as zero. |
| Sandbox | Defense in depth for a trusted operator: no host mounts, no Docker socket, dropped capabilities, resource limits, a network allowlist. Not a multi-tenant boundary. Add read_only=True and runtime="runsc" (gVisor) for more. Full threat model in SECURITY.md. |
| Key isolation | With inject_headers the key never enters the sandbox. Sandboxed code can still spend it through the proxy; the proxy only stops it from being stolen or swapped for another account's. |
| Tool timeline | Outer commands always. Codex's internal tool calls only via codex exec --json with an exact CLI version; gaps are reported as incomplete coverage, never guessed from prose. |
Reference #
Files tbcore writes
| Path | Contents |
|---|---|
<root>/experiments.sqlite3 | Specs, runs, attempts, ordered events. Schema user_version=1. |
<root>/artifacts/ | Hash-checked run outputs, under the capture policy. |
<root>/workspaces/ | Snapshot manifests and content-addressed blobs. |
.tbcore/calls.jsonl | Default CallLog for direct Client calls, one JSON line each. Moves with TBCORE_LOG_PATH. |
Docker names
| Name pattern | What it is |
|---|---|
tbcore-run-* | One-shot Sandbox.run container. |
tbcore-sess-* | Session container (trials use these). Labelled owner=tbcore. |
tbcore-proxy-* | Forward and credential proxy for one Sandbox. |
tbcore-net-* | Internal network joining a sandbox to its proxy. Labelled owner=tbcore. |
Model config file
Optional. Copy tbcore.example.toml, then load it by path with load_model_config("tbcore.toml") and pass Client(name, config=config). tbcore never finds the file on its own. It sets endpoints, the name of the key variable (api_key_env), and prices; a literal api_key is an error. A config entry wins over a built-in of the same name, and a name in neither raises instead of being sent as a raw model id. allow_hosts and inject_headers are not read from it.
Environment variables
| Variable | Used for |
|---|---|
TBCORE_LOG_PATH | Where CallLog writes when no path is passed. |
NO_COLOR, FORCE_COLOR | Turn off, or keep, the level colors of configure_logging(). Without either, color is used only on an interactive terminal. |
ANTHROPIC_API_KEY, OPENAI_API_KEY | Read on the host by Client. Hand them to sandboxes with inject_headers, never env=. |
TBCORE_RUN_LIVE_CODEX, TBCORE_CODEX_VERSION, TBCORE_CODEX_MODEL, TBCORE_LIVE_REPORT | Opt-in paid Codex smoke test (pytest -m live). Capped at 4 calls and 2,000 tokens. |