Skip to content

Guides

Testing agents locally

Your agent, test runner, and editor stay on your machine. Only its vendor destinations change: each selected service points at an account-bound twin with deterministic seeded state and no production fallback. The plain create call answers in seconds on the website plane (twe_*); the cluster plane (tenv_*) is an explicit choice.

How it works

  1. Provision. blobfish twin-runs create --twins stripe --ttl 60 --wait (or blobfish twin wizard) assembles a seeded environment on the website plane and reports ready in seconds.
  2. Load routing.env. It carries endpoints only — the world MCP server, one MCP URL per twin, the run and console URLs — never the API key, which stays in your secret store.
  3. Point your code at the twin. MCP clients get the per-twin MCP URL. HTTP clients and vendor SDKs get the vendor-path REST base, …/services/<twin>/rest, and keep their own paths: /v1/payment_intents stays /v1/payment_intents. Add X-API-Key, and X-Blobfish-Session when the run should work in a private fork of the seeded state.
  4. Run your tests. The runner you already use, where it already runs. Each session is a copy-on-write fork; reset restores the seeded baseline between cases; the ledger records every call.
  5. Tear down. blobfish twin-runs delete twe_<id> (or twin wizard teardown) in a finally step. Delisting is immediate; the lease is bookkeeping.

Provision and load routes

Terminal
$ blobfish twin-runs create --twins github,slack --ttl 60 --wait
$ set -a && source routing.env && set +a
$ test "$BLOBFISH_PRODUCTION_FALLBACK" = 0

The receipt includes one MCP URL per twin plus the world-level MCP endpoint. Store the API key in your secret manager or shell, not in source control; routing.env contains no credential. Or let the wizard do it: blobfish twin wizard --twins github,slack provisions, appends a managed block to your .env after backing it up to .env.blobfish-backup, and writes .blobfish-session.json; then blobfish twin wizard status|reset|extend|teardown manage that session and cp .env.blobfish-backup .env restores your original variables. The step-by-step account is in the quickstart.

Point the local agent at the Twins

MCP configuration
{
  "mcpServers": {
    "github": {
      "url": "${BLOBFISH_TWIN_GITHUB_MCP}",
      "headers": { "X-API-Key": "${BLOBFISH_API_KEY}" }
    },
    "slack": {
      "url": "${BLOBFISH_TWIN_SLACK_MCP}",
      "headers": { "X-API-Key": "${BLOBFISH_API_KEY}" }
    }
  }
}

Replace the interpolation syntax with whatever your MCP client uses. Preserve the Mcp-Session-Id returned by initialization: it selects that rollout’s copy-on-write state on later calls.

Use one environment per test job

  1. Create with a stable --idempotency-key so retrying a timed-out CI request cannot allocate a second environment.
  2. Wait for observed ready before starting the agent; seed artifacts are mounted before readiness.
  3. Run parallel MCP sessions inside the environment. Each session mutates its own copy-on-write database.
  4. Reset the environment between cases with blobfish twin-runs reset twe_<id>. Reset restores shared state and every extant session fork to the seeded baseline; on the cluster plane it restores every provider from its sealed baseline.
  5. Delete in a CI finally step. Website environments are delisted immediately; cluster teardown waits until Argo CD removes every owned Application and workload.

Example: a payments flow on the Stripe twin

A checkout test that must create a payment intent, record the charge, and read the books back — against the vendor’s own paths, with the vendor’s own field names, inside a private session.

Terminal
$ blobfish twin-runs create --twins stripe --ttl 60 --wait
$ set -a && source routing.env && set +a
$ WORLD=$(python3 -c 'import json; print(json.load(open(".blobfish-twin-run.json"))["world_id"])')
$ STRIPE="https://blobfish.ai/api/v1/sandbox/worlds/$WORLD/services/stripe/rest"
$ SESSION=$(curl -sS -X POST "https://blobfish.ai/api/v1/sandbox/worlds/$WORLD/sessions" \
    -H "X-API-Key: $BLOBFISH_API_KEY" -H "Content-Type: application/json" -d '{}' \
    | python3 -c 'import json,sys; print(json.load(sys.stdin)["session_id"])')

# 1. create the payment intent
$ curl -sS -X POST "$STRIPE/v1/payment_intents" \
    -H "X-API-Key: $BLOBFISH_API_KEY" -H "X-Blobfish-Session: $SESSION" \
    -H "Content-Type: application/json" \
    -d '{"amount":4900,"currency":"usd","description":"Order #4821"}'
# → 200 {"id":"pi_…","status":"requires_payment_method","amount":4900,"currency":"usd",…}
#   x-blobfish-operation-id: PostPaymentIntents · x-blobfish-mutated: true · x-blobfish-backed-by: session_world

# 2. attach the receipt email (POST /v1/payment_intents/{intent} is the vendor's update verb)
$ curl -sS -X POST "$STRIPE/v1/payment_intents/pi_<id>" \
    -H "X-API-Key: $BLOBFISH_API_KEY" -H "X-Blobfish-Session: $SESSION" \
    -H "Content-Type: application/json" -d '{"receipt_email":"ada@example.test"}'

# 3. record the charge, then read the books back
$ curl -sS -X POST "$STRIPE/v1/charges" \
    -H "X-API-Key: $BLOBFISH_API_KEY" -H "X-Blobfish-Session: $SESSION" \
    -H "Content-Type: application/json" \
    -d '{"amount":4900,"currency":"usd","description":"Order #4821"}'
# → 200 {"id":"ch_…","status":"succeeded","amount":4900,…}
$ curl -sS "$STRIPE/v1/payment_intents?limit=3" \
    -H "X-API-Key: $BLOBFISH_API_KEY" -H "X-Blobfish-Session: $SESSION"
$ curl -sS "$STRIPE/v1/balance_transactions?limit=2" \
    -H "X-API-Key: $BLOBFISH_API_KEY" -H "X-Blobfish-Session: $SESSION"
# → 200 {"object":"list","data":[…],"has_more":false,"url":"/v1/balance_transactions"}

# 4. the failures are honest
$ curl -sS -i -X DELETE "$STRIPE/v1/payment_intents/pi_<id>" \
    -H "X-API-Key: $BLOBFISH_API_KEY" -H "X-Blobfish-Session: $SESSION"
# → 405 · allow: GET, POST
$ curl -sS -X POST "$STRIPE/v1/payment_intents" \
    -H "X-API-Key: $BLOBFISH_API_KEY" -H "X-Blobfish-Session: $SESSION" \
    -H "Content-Type: application/json" -d '{"currency":"usd"}'
# → 400 {"error":"invalid_arguments","details":[{"path":"amount","code":"missing_required_argument","expected":"integer"}]}

What the twin honours is the vendor’s: paths and methods, parameter names, which fields are required, the rejection of fields it does not declare, the body encodings its SDKs send (JSON, application/x-www-form-urlencoded with Stripe’s bracket nesting, multipart), and its envelopes — lists come back as Stripe’s object: "list" / data / has_more, Slack’s ok / channels, GitHub’s bare arrays, Salesforce’s totalSize / records, the way each SDK parses them (X-Blobfish-Envelope: native returns the twin’s own items + count; MCP tools always do). What is the twin’s own: columns you did not set come back null, pagination is a single page, and a single-twin environment mounts up to 120 of a spec’s operations (80 per twin at three; a wider stack shares 240 tools), chosen headline-first — customers, payment intents, charges, subscriptions, invoices, products and their read-backs before issuing, treasury and terminal; Stripe declares 587 operations, so GET …/services/stripe (the manifest) is the list of what this environment actually serves, and a spec path outside that slice answers 400 operation_not_mounted (No world-backed tool found), in the vendor’s own error envelope.

A stock vendor SDK therefore works by changing two settings: its base URL, and its token — your bf_ key, which the twin reads from Authorization: Bearer exactly as it reads X-API-Key.

Vendor SDKs, unchanged
# stripe-python: base URL + key, keep every call as written
import os, stripe
stripe.api_base = f"https://blobfish.ai/api/v1/sandbox/worlds/{os.environ['WORLD']}/services/stripe/rest"
stripe.api_key = os.environ["BLOBFISH_API_KEY"]        # bf_… — the twin authenticates YOU, never a vendor identity
customer = stripe.Customer.create(email="ada@example.test", name="Ada", metadata={"plan": "pro"})
intent = stripe.PaymentIntent.create(amount=4900, currency="usd", customer=customer.id)
assert stripe.PaymentIntent.retrieve(intent.id).amount == 4900   # the read-back is mounted
print(stripe.PaymentIntent.list(limit=3).data[0].id)               # object: list · data · has_more

# slack_sdk: base_url + token; the SDK POSTs every method, reads included
from slack_sdk import WebClient
slack = WebClient(token=os.environ["BLOBFISH_API_KEY"],
                  base_url=f"https://blobfish.ai/api/v1/sandbox/worlds/{os.environ['WORLD']}/services/slack/rest/")
channel = slack.conversations_create(name="incident-4821")["channel"]["id"]
slack.chat_postMessage(channel=channel, text="Rolling back deploy 2026.09.02")
print([c["name"] for c in slack.conversations_list(limit=3)["channels"]])

# A private fork per test: add X-Blobfish-Session (see above) as a default header,
# e.g. stripe.default_http_client = stripe.http_client.RequestsClient(session=requests_session_with_header)

Example: a messaging workflow on the Slack twin

An incident bot that opens a channel, posts the first update, reacts to it, and archives the channel when the incident closes. Slack’s method-style paths are served verbatim.

Terminal
$ blobfish twin-runs create --twins slack --ttl 60 --wait
$ WORLD=$(python3 -c 'import json; print(json.load(open(".blobfish-twin-run.json"))["world_id"])')
$ SLACK="https://blobfish.ai/api/v1/sandbox/worlds/$WORLD/services/slack/rest"
# open a session as above, then:

$ curl -sS -X POST "$SLACK/conversations.create" \
    -H "X-API-Key: $BLOBFISH_API_KEY" -H "X-Blobfish-Session: $SESSION" \
    -H "Content-Type: application/json" -d '{"name":"incident-4821","is_private":false}'
# → 200 {"id":"C0000109","name":"incident-4821","is_private":0,…}
$ curl -sS -X POST "$SLACK/chat.postMessage" \
    -H "X-API-Key: $BLOBFISH_API_KEY" -H "X-Blobfish-Session: $SESSION" \
    -H "Content-Type: application/json" \
    -d '{"channel":"C0000109","text":"Rolling back deploy 2026.09.02 — status in thread"}'
# → 200 {"ts":"1767612060.000000","type":"message","text":"Rolling back …","user":"U…",…}
$ curl -sS -X POST "$SLACK/reactions.add" \
    -H "X-API-Key: $BLOBFISH_API_KEY" -H "X-Blobfish-Session: $SESSION" \
    -H "Content-Type: application/json" \
    -d '{"channel":"C0000109","name":"eyes","timestamp":"1767612060.000000"}'
$ curl -sS "$SLACK/conversations.list?limit=3" \
    -H "X-API-Key: $BLOBFISH_API_KEY" -H "X-Blobfish-Session: $SESSION"
$ curl -sS -X POST "$SLACK/conversations.archive" \
    -H "X-API-Key: $BLOBFISH_API_KEY" -H "X-Blobfish-Session: $SESSION" \
    -H "Content-Type: application/json" -d '{"channel":"C0000109"}'
# → 200 {"ok":true,"action":"archive",…}
$ curl -sS -i "$SLACK/chat.postMessage" -H "X-API-Key: $BLOBFISH_API_KEY"
# → 405 · allow: POST

$ blobfish twin-runs logs twe_<id> --limit 10   # slack_chat_post_message, args, ok, session, request id

reactions.add requires channel, name, and timestamp exactly as Slack does; the same workflow runs over MCP through BLOBFISH_TWIN_SLACK_MCP with the tool names the ledger shows. Slack declares 174 operations; the environment’s manifest lists the 120 it serves.

Tips

  1. Add routing.env, .blobfish-twin-run.json, .blobfish-session.json, .env.blobfish-backup, and .blobfish-twin-provision-*.json to .gitignore. They hold environment ids, world ids, and your pre-wizard variables; the API key is never in them, but the environment they name is yours.
  2. One environment per CI job, created with --idempotency-key "$CI_JOB_ID"; one session per test case, or twin-runs reset between cases.
  3. Tear down in finally: blobfish twin-runs delete twe_<id>. If the job dies first, the lease expires on its own and the environment is simply labelled expired.
  4. Point base URLs, keep paths: set your vendor client’s base URL to …/services/<twin>/rest and add X-API-Key as a default header. The vendor’s own auth header can stay; it is ignored when X-API-Key is present.
  5. Treat the ledger as the assertion of record: blobfish twin-runs logs twe_<id> shows exactly which tools ran with which arguments, in which session.
  6. There is no hosted browser test-runner. Blobfish twins are backend twins; nothing here drives or renders a UI. Playwright, pytest, or vitest keep running where they always did, and only the vendor API behind your application changes.

Inspect failures without payload leakage

Evidence
$ blobfish twin-runs show twe_<id> --json
$ blobfish twin-runs logs twe_<id> --limit 100
$ blobfish twin-runs health twe_<id>
$ open "$BLOBFISH_TWIN_CONSOLE_ENDPOINT"

The website-plane ledger records every REST, MCP, CLI, and model-run call the environment served (tool, arguments, outcome, session, request id). The cluster-plane ledger is digest-only: operation names, outcomes, status, latency, opaque session references, and request/response digests — never bodies, credentials, or raw session ids.

Current public boundary

Website environments expose per-service MCP, vendor-path REST (…/services/<svc>/rest/<vendor path>), operation calls, reset, sessions, and a generated CLI; cluster environments expose the account-bound MCP routes only. Catalog labels such as API + UI describe the admitted provider model; they do not promise arbitrary vendor-domain replacement or browser cloning. There is no hosted test-runner and no browser test harness: a twin replaces the vendor API, not your test tooling. Unsupported calls remain blocked.

Next

Custom scenarios · Models · Quickstart · Start a twin run