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
- Provision.
blobfish twin-runs create --twins stripe --ttl 60 --wait(orblobfish twin wizard) assembles a seeded environment on the website plane and reportsreadyin seconds. - 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. - 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_intentsstays/v1/payment_intents. AddX-API-Key, andX-Blobfish-Sessionwhen the run should work in a private fork of the seeded state. - 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.
- Tear down.
blobfish twin-runs delete twe_<id>(ortwin wizard teardown) in afinallystep. Delisting is immediate; the lease is bookkeeping.
Provision and load routes
$ blobfish twin-runs create --twins github,slack --ttl 60 --wait
$ set -a && source routing.env && set +a
$ test "$BLOBFISH_PRODUCTION_FALLBACK" = 0The 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
{
"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
- Create with a stable
--idempotency-keyso retrying a timed-out CI request cannot allocate a second environment. - Wait for observed
readybefore starting the agent; seed artifacts are mounted before readiness. - Run parallel MCP sessions inside the environment. Each session mutates its own copy-on-write database.
- 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. - Delete in a CI
finallystep. 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.
$ 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.
# 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.
$ 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 idreactions.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
- Add
routing.env,.blobfish-twin-run.json,.blobfish-session.json,.env.blobfish-backup, and.blobfish-twin-provision-*.jsonto.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. - One environment per CI job, created with
--idempotency-key "$CI_JOB_ID"; one session per test case, ortwin-runs resetbetween cases. - 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. - Point base URLs, keep paths: set your vendor client’s base URL to
…/services/<twin>/restand addX-API-Keyas a default header. The vendor’s own auth header can stay; it is ignored whenX-API-Keyis present. - 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. - 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
$ 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.