$ tbcorerunbook

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
  1. Fastest path
  2. Prepare the host
  3. Run an experiment
  4. Verify the result
  5. Recover
  6. Troubleshoot
  7. What tbcore promises
  8. Reference

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.

shell
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.

  1. 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.

    shell
    uv --version
    docker version --format '{{.Server.Version}}'

    ExpectTwo version numbers. An error mentioning docker API or dockerDesktopLinuxEngine means the daemon is not running: start Docker Desktop, or sudo systemctl start docker on Linux.

  2. Install with uv #

    Install from a checkout so the bundled examples and scripts are on hand; uv add tbcore works too if you only need the library. uv sync creates .venv and installs tbcore in editable mode. The dev extra brings pytest, ruff, and mypy; drop --extra dev if you only run experiments.

    shell
    uv 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 through uv run, which uses this environment.

  3. 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.

    shell
    uv 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 pass images["sandbox"] and proxy_image=images["proxy"] to Sandbox. Unreleased checkouts and offline hosts keep building.

  4. Prove the host works with the fixture #

    The example runs the same tiny task twice (baseline writes before, candidate writes after), grades both, exports the candidate, and replays it in a fresh store. It reuses the image you just built.

    shell
    uv run python examples/experiment_workflow.py --store .tbcore/example --image tbcore/sandbox:latest

    ExpectThree run IDs, plus .tbcore/example/comparison.md, .tbcore/example/candidate-bundle.json, and a replay/ 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.

  1. Build your agent image #

    Trials run with no general network access, so pip install inside the container will fail. Bake the agent's dependencies into an image built on the tbcore sandbox. Pin every version in requirements.txt; a floating SDK version is a changed environment between runs.

    agent-image/Dockerfile
    FROM 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 sandbox
    shell
    docker build -t parser-agent:2026-10-15 agent-image/
    docker image inspect --format '{{.Id}}' parser-agent:2026-10-15

    ExpectA sha256:... image ID. Tag with a date or version, never latest, so a rebuild can't silently change what a rerun uses.

  2. 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.py
    import 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.sqlite3 appears. Use one store root per experiment and one writing process per root.

  3. 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 .agent are skipped by default; symlinks are rejected rather than followed. Then record what the run will execute on.

    python · experiment.py
    from 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"},
        )
    )

    Expectsnapshot.snapshot_id and env_ref are plain strings. Binary files in the task directory need ExperimentStore(..., allow_binary_workspaces=True), because binary bytes can't be redacted.

  4. Define the trials #

    One TrialCase per 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.py
    from 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.

  5. 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.json for the agent to read. With inject_headers, the API key stays in the proxy container: the agent sees ANTHROPIC_BASE_URL pointing 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_budget applies 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.py
    from 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, and interrupted attempts 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 longer Sandbox(timeout_s=...).

  6. 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.py
    from 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)

    Expectpass or fail per run. error means the grader itself broke (timeout, missing image, missing artifact); that is kept separate from the agent's own status. For exact-match checks use EvaluationSpec(..., expected_text={"answer.txt": "42\n"}), which needs no image.

  7. 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.py
    from 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)

    Expectunknown_calls of 0 and a numeric total. If total is None, at least one call had missing usage or a model ID that isn't in rates. That's deliberate: tbcore will not turn an unknown into zero. New prices later? Call price_run(..., recalculate=True) with a new card; the original estimate stays.

  8. 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.py
    from 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.

    Expectcomparison.md showing 5 trials per side. Fewer means some trials failed execution or grading; the report says which.

  9. 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.py
    best = 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()
    shell
    docker ps -a --filter name=tbcore- --format '{{.Names}}  {{.Status}}'
    docker network ls --filter label=owner=tbcore

    ExpectBoth 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.

shell
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.

shell
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 #

  1. Copy the backup into a new directory. Don't overwrite the damaged one; you may want it for diagnosis.
  2. Point ROOT at the new directory and open it with the same tbcore version that wrote it.
  3. 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.

  1. Back up the store (R2) and note the current package version and image IDs.
  2. Upgrade the package, rebuild images (step 3), and try the upgrade on a copy of the store first.
  3. 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.

shell · inspect
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.

shell · remove
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.

python · replay.py
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.

SymptomLikely causeFix
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 APIDocker daemon isn't running.Start Docker Desktop, or sudo systemctl start docker.
proxy did not become ready in timeSlow 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 tracebackThe 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 outThe 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 1The 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.
BudgetExceededA 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.
TrialOwnedErrorAnother live process owns the trial.Stop the other runner. One writing process per store root.
unsupported store schema version: 2The store was written by a newer tbcore.Install that version, or restore an older backup (R4).
workspace contains a symlinkSymlinks 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 limitAn output is over max_artifact_bytes (1 MiB by default).Raise CapturePolicy(max_artifact_bytes=...), or capture a smaller output.
content capture is disabledMetadata-only policy, but something tried to store content.Set CapturePolicy(content_enabled=True) if storing content is approved.
copy_out needs Python >= 3.11.4Old Python patch release.Recreate the environment on a newer Python: uv sync --python 3.12.
path must not contain '..' and similarAn input file name tried to escape /work.Use relative names below /work, with forward slashes.
Cost total is NoneA 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 startThe 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 saysWhat that actually means
RepeatableInputs, 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 limitsAdmission 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.
CostAn estimate from your dated price card. Not an invoice. Unknown usage stays unknown and is never counted as zero.
SandboxDefense 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 isolationWith 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 timelineOuter 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

PathContents
<root>/experiments.sqlite3Specs, 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.jsonlDefault CallLog for direct Client calls, one JSON line each. Moves with TBCORE_LOG_PATH.

Docker names

Name patternWhat 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

VariableUsed for
TBCORE_LOG_PATHWhere CallLog writes when no path is passed.
NO_COLOR, FORCE_COLORTurn off, or keep, the level colors of configure_logging(). Without either, color is used only on an interactive terminal.
ANTHROPIC_API_KEY, OPENAI_API_KEYRead 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_REPORTOpt-in paid Codex smoke test (pytest -m live). Capped at 4 calls and 2,000 tokens.