Run Daytona Sandboxes on a Forkable Tigris Bucket | Tigris Object Storage Documentation
Daytona and Tigris Integration
On this page
Daytona gives your agent a disposable microVM to run in. Tigris gives it a durable, forkable world to live in. This guide wires the two together end to end: every run gets a private copy-on-write fork of a base bucket and an access key scoped to that fork, the sandbox boots already bound to its fork, and when the run ends the harness promotes the changes or throws them away.
The properties you get from this split:
- Isolation that is enforced, not conventional. The sandbox only ever holds credentials for its own fork. A prompt injection or a buggy tool can name the base bucket all it wants; the API will refuse it.
- Free experiments. A fork is a metadata-level, zero-copy clone, so it costs the same whether the base holds megabytes or terabytes. Deleting a fork frees only the deltas the run actually wrote.
- One snapshot boundary. Working files, memory, and run metadata live as keys in one namespace, so "what did the world look like before this run" is a snapshot, not a forensics project.
Prerequisites
- A Tigris account and the Tigris CLI installed and authenticated on the machine that runs your harness (
npm install -g @tigrisdata/cli) - A Daytona API key, exported as
DAYTONA_API_KEY - The Daytona Python SDK:
pip install daytona
Step 1: Create the base bucket
The base bucket is the agent's world: the one layer you can't rebuild. Enable snapshots on it so every promotion below can be preceded by a point-in-time marker you can fork from later.
tigris buckets create agent-world --enable-snapshots
Seed it with whatever your agent needs to start: a repo under workspace/, memory under memory/, run records under runs/.
Step 2: Write the entrypoint
The entrypoint is baked into the Daytona snapshot and runs before your agent's first instruction. It arrives holding fork-scoped credentials and nothing else, pulls the working set, and binds every storage tool to the fork.
#!/usr/bin/env bash
# entrypoint.sh
set -euo pipefail
: "${FORK:?FORK is not set}"
: "${TIGRIS_ACCESS_KEY_ID:?TIGRIS_ACCESS_KEY_ID is not set}"
: "${TIGRIS_SECRET_ACCESS_KEY:?TIGRIS_SECRET_ACCESS_KEY is not set}"
export AWS_ACCESS_KEY_ID="${TIGRIS_ACCESS_KEY_ID}"
export AWS_SECRET_ACCESS_KEY="${TIGRIS_SECRET_ACCESS_KEY}"
export AWS_REGION=auto
export AWS_ENDPOINT_URL_S3=https://t3.storage.dev
export AWS_ENDPOINT_URL_IAM=https://iam.storage.dev
tigris cp -r "t3://${FORK}/workspace/" /workspace/
export STORAGE_ADAPTER=tigris
export TIGRIS_BUCKET="${FORK}"
exec "$@"
Step 3: Bake the Daytona snapshot
Build a snapshot image that contains the Tigris CLI, the storagesdk MCP server, the entrypoint, and an MCP config entry so the agent can reach its world by key. Run this once, not per run.
# build_snapshot.py
from daytona import CreateSnapshotParams, Daytona, Image
image = (
Image.debian_slim("3.13")
.run_commands(
"apt-get update && apt-get install -y nodejs npm",
"npm install -g @tigrisdata/cli @storagesdk/cli",
)
.add_local_file("entrypoint.sh", "/entrypoint.sh")
.add_local_file("mcp.json", "/etc/agent/mcp.json")
.run_commands("chmod +x /entrypoint.sh")
)
daytona = Daytona() # reads DAYTONA_API_KEY
daytona.snapshot.create(
CreateSnapshotParams(name="agent-runtime", image=image),
on_logs=print,
)
The MCP config is one entry; point your agent framework at wherever it expects this file (for Claude Code, a .mcp.json in the workspace root):
{
"mcpServers": {
"world": { "command": "storage", "args": ["mcp"] }
}
}
Because the entrypoint exported TIGRIS_BUCKET as the fork, every tool the server offers (download, upload, ls, snapshot_create) is already aimed at the fork, and the fork-scoped key is what makes the aim binding.
Step 4: The harness: one fork, one key, one sandbox per run
This is the complete per-run lifecycle: fork the world, mint a key that can see only the fork, boot the sandbox with those two facts in its environment, then promote or discard from the harness. The sandbox never holds credentials that can touch the base.
# run.py
import json
import subprocess
from collections.abc import Callable
from daytona import CreateSandboxFromSnapshotParams, Daytona
BASE_BUCKET = "agent-world"
SNAPSHOT = "agent-runtime"
def tigris(*args: str) -> str:
return subprocess.run([
"tigris", *args], check=True, capture_output=True, text=True
).stdout
def validate(fork: str) -> bool:
return False
def run_agent(run_id: str, command: str, validate: Callable[[str], bool]) -> None:
fork = f"run-{run_id}"
tigris("buckets", "create", fork, "--fork-of", BASE_BUCKET)
key = json.loads(
tigris("access-keys", "create", f"sandbox-{run_id}", "--format", "json")
)
tigris("access-keys", "assign", key["id"], "-b", fork, "-r", "Editor")
daytona = Daytona()
sandbox = daytona.create(
CreateSandboxFromSnapshotParams(
snapshot=SNAPSHOT,
env_vars={
"FORK": fork,
"TIGRIS_ACCESS_KEY_ID": key["id"],
"TIGRIS_SECRET_ACCESS_KEY": key["secret"],
},
)
)
try:
result = sandbox.process.exec(f"/entrypoint.sh {command}", timeout=3600)
if result.exit_code == 0 and validate(fork):
tigris("snapshots", "take", BASE_BUCKET)
tigris(
"cp", "-r",
f"t3://{fork}/workspace/",
f"t3://{BASE_BUCKET}/workspace/",
)
tigris("rm", f"t3://{fork}", "-f")
finally:
tigris("access-keys", "delete", key["id"], "--yes")
sandbox.delete()
if __name__ == "__main__":
run_agent(
run_id="4187",
command="python -m agent",
validate=validate,
)
validate ships as a stub that returns False, so nothing is promoted until you wire your eval suite into it. Failed runs cost you nothing but the deltas they wrote.
One deliberate asymmetry in the cleanup: the access key and the sandbox are deleted in finally, so they die even when the run crashes or times out, but the fork is only deleted on the happy path. A run that died mid-flight leaves its fork behind, with dead credentials, so you can inspect exactly what the agent did before it failed. Sweep old run-* buckets on whatever schedule suits you.
Optional: mount the fork instead of copying
If you'd rather not copy even the working set, Daytona documents mounting a Tigris bucket straight into the sandbox with mount-s3 and the https://t3.storage.dev endpoint. Bake mount-s3 into the snapshot, replace the tigris cp line in the entrypoint with a mount of ${FORK}, and the whole world shows up as a local directory. The fork-scoped key works unchanged, because mount-s3 reads the same AWS_* variables the entrypoint already exports.