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:

Prerequisites

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.