Guides
Gateway & health
One environment, one card. The gateway descriptor lists every URL the environment serves and how to call it, the way a hosted-endpoint card reads. The health route is a probe, not a status field: it makes real calls and reports each one. Both serve website (twe_) and cluster (tenv_) environments from the same route.
Read the descriptor
$ curl -sS "$BLOBFISH_BASE_URL/api/v1/twin/environments/twe_<id>/gateway" \
-H "X-API-Key: $BLOBFISH_API_KEY"| Field | Meaning |
|---|---|
env_id · plane | The environment and which plane serves it: website (twe_, sandbox-world runtime) or cluster (tenv_, GitOps projection). |
status · lease | Observed status, plus expires_at / expired / remaining_seconds. An expired website lease returns 410 on world surfaces until extended; termination revokes them permanently without deleting retained world data. |
auth | header is always x-api-key (Authorization: Bearer bf_… also works). The note says exactly which calls need the key. |
base_url | The public origin the descriptor was read from — every URL below is absolute against it. |
services[] | Per mounted twin: mcp_url, and on the website plane rest_base_url, operations_url, manifest_url, state_url; reset_url on both; tools (served names) with tools_source. |
sessions_url · session_header | Website plane: POST sessions_url with the key, then send X-Blobfish-Session. Cluster plane: initialize returns Mcp-Session-Id; send it back. |
activity_url · cli_url · health_url | The call ledger, the generated CLI (website service twins), and the health probe — all real routes on this deployment. |
agent_loopback | Cluster plane only: the credential-free sidecar endpoints an agent pod uses, http://127.0.0.1:8765/v1/mcps/<service>/mcp. |
examples | curl, python, and typescript snippets built from this environment's own URLs and first served tool: tools/list, a session, tools/call. |
The console renders the same descriptor in the Gateway section of /twin/<env_id>, with copy buttons and the examples per language.
Authenticate and pin a session
$ SESSION=$(curl -sS -X POST "$BLOBFISH_BASE_URL/api/v1/sandbox/worlds/<world_id>/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"])')
$ curl -sS -X POST "$BLOBFISH_BASE_URL/api/v1/sandbox/worlds/<world_id>/services/slack/mcp" \
-H "X-API-Key: $BLOBFISH_API_KEY" -H "X-Blobfish-Session: $SESSION" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'$ curl -sS -D /tmp/mcp-headers.txt -X POST "$BLOBFISH_BASE_URL/api/v1/twin/environments/tenv_<id>/mcps/trolley/mcp" \
-H "X-API-Key: $BLOBFISH_API_KEY" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
$ SESSION=$(grep -i '^mcp-session-id:' /tmp/mcp-headers.txt | cut -d' ' -f2 | tr -d '\r')Website Twin world reads and writes are private: send the owning bf_ key or an allowed delegated bft_ environment token. Mcp-Session-Id is honoured as an alias for legacy MCP clients on the website plane and is the primary session header on the cluster plane. Keys are never echoed back in any descriptor or ledger.
Hand an agent a token, not your key
An environment token (bft_…) is a delegated credential bound to one environment and its world. It is accepted only on that world’s runtime routes — the world MCP, sessions (including item reset/delete), and services/<twin>/{mcp,rest,operations,state,reset} — and refused by name everywhere else: the twin console API, other worlds, after revocation, after expiry. read covers GETs and MCP reads; readwrite adds writes, resets, deletes, and isolated session forks. Only the hash is stored; the secret is shown once and expires with the lease unless you set ttl_minutes. Ten live tokens per environment.
$ curl -sS -X POST "$BLOBFISH_BASE_URL/api/v1/twin/environments/twe_<id>/tokens" \
-H "X-API-Key: $BLOBFISH_API_KEY" -H "Content-Type: application/json" \
-d '{"scope":"readwrite","name":"ci-agent"}'
# → 201 {"token_id":"tok_…","token":"bft_…","scope":"readwrite","expires_at":"…","use":{"header":"X-API-Key","routes":{…}}}
# the agent sends the token where it would have sent your key — and nowhere else works
$ curl -sS "$BLOBFISH_BASE_URL/api/v1/sandbox/worlds/<world_id>/services/slack/rest/conversations.list" -H "X-API-Key: bft_…"
$ curl -sS "$BLOBFISH_BASE_URL/api/v1/twin/environments/twe_<id>" -H "X-API-Key: bft_…"
# → 401 env_token_route_not_allowed … environment tokens are accepted only on that world's mcp, sessions, and services/* routes
$ curl -sS "$BLOBFISH_BASE_URL/api/v1/twin/environments/twe_<id>/tokens" -H "X-API-Key: $BLOBFISH_API_KEY"
$ curl -sS -X DELETE "$BLOBFISH_BASE_URL/api/v1/twin/environments/twe_<id>/tokens/tok_<id>" -H "X-API-Key: $BLOBFISH_API_KEY"
$ blobfish twin-runs token twe_<id> --scope readwrite # CLI shapeThe gateway descriptor names both surfaces (tokens_url, webhooks_url) beside sessions_url; see Webhooks for the signed event stream.
Behavioural policy: scopes, rate limits, a clock
A twin’s data is its scenario; its behaviour is its policy. Every knob is off until you set it — PUT …/policy replaces the whole policy (omitted sections reset), POST …/policy merges the sections you send, and policy in the create body applies one from the first call. Tokens are the vendor’s shape, invented by you (xoxb-…, ghp_…, rk_test_…); a client presents one as Authorization: Bearer beside the X-API-Key that authenticates it to Blobfish. The token you present is compared, never persisted; the tokens you declare are stored beside the environment and read back by GET …/policy — so they must be invented (live-secret shapes, sk_live_ / rk_live_ / pk_live_, are refused). A refusal wears the vendor’s envelope and carries x-blobfish-policy naming which knob fired.
$ curl -sS -X PUT "$BLOBFISH_BASE_URL/api/v1/twin/environments/twe_<id>/policy" \
-H "X-API-Key: $BLOBFISH_API_KEY" -H "Content-Type: application/json" \
-d '{"scopes":{"enabled":true,"tokens":[{"token":"xoxb-rehearsal-1","scopes":["channels:read"]}]}}'
$ curl -sS -X POST "$BLOBFISH_BASE_URL/api/v1/sandbox/worlds/<world_id>/services/slack/rest/chat.postMessage" \
-H "X-API-Key: $BLOBFISH_API_KEY" -H "Authorization: Bearer xoxb-rehearsal-1" \
-H "Content-Type: application/json" -d '{"channel":"C01","text":"hello"}'
# → 200 {"ok":false,"error":"missing_scope","needed":"chat:write","provided":"channels:read"} (x-blobfish-policy: missing_scope)
# grant it — a merge keeps scopes.enabled and replaces the token list
$ curl -sS -X POST "$BLOBFISH_BASE_URL/api/v1/twin/environments/twe_<id>/policy" \
-H "X-API-Key: $BLOBFISH_API_KEY" -H "Content-Type: application/json" \
-d '{"scopes":{"tokens":[{"token":"xoxb-rehearsal-1","scopes":["channels:read","chat:write"]}]}}'
# → the same chat.postMessage now answers 200 {"ok":true,…}# two conversations.list per minute per credential; the third is the vendor's 429
$ curl -sS -X POST "$BLOBFISH_BASE_URL/api/v1/twin/environments/twe_<id>/policy" \
-H "X-API-Key: $BLOBFISH_API_KEY" -H "Content-Type: application/json" \
-d '{"rate_limits":{"enabled":true,"rules":{"conversations.list":{"window_seconds":60,"max_requests":2}}}}'
# → third GET …/services/slack/rest/conversations.list within 60 s: 429 {"ok":false,"error":"ratelimited"} Retry-After: 58
# move the twin's clock an hour ahead: the next created row's created_at (and the next Slack message ts) is an hour later
$ curl -sS -X POST "$BLOBFISH_BASE_URL/api/v1/twin/environments/twe_<id>/policy/clock/advance" \
-H "X-API-Key: $BLOBFISH_API_KEY" -H "Content-Type: application/json" -d '{"seconds":3600}'
# → {"clock":{"offset_seconds":3600,"epoch":"2026-01-05T09:00:00Z","now":"…"}}
# what did the agent call that the twin does not serve?
$ curl -sS "$BLOBFISH_BASE_URL/api/v1/twin/environments/twe_<id>/unmounted-hits" -H "X-API-Key: $BLOBFISH_API_KEY"
# → {"total":2,"unique":1,"summary":[{"key":"rest stripe GET /v1/nothing","count":2,…}],"recent":[…]}
$ curl -sS -X POST "$BLOBFISH_BASE_URL/api/v1/twin/environments/twe_<id>/unmounted-hits/clear" -H "X-API-Key: $BLOBFISH_API_KEY"| Section | Fields and behaviour |
|---|---|
scopes | enabled, tokens[] of {token, scopes[], is_bot?} (at most 50), disabled_scopes[] withdrawn from every token. Required scope by static table — Slack by method family (chat.* → chat:write, conversations.list|info|members → channels:read, conversations.history|replies → channels:history, conversations.create|archive|invite|rename|setTopic → channels:manage, users.* → users:read, files.* → files:read|files:write, …); GitHub by path family with parent grants (repo ⇒ repo:status, repo_deployment, public_repo, security_events; admin:repo_hook ⇒ write:repo_hook ⇒ read:repo_hook; repositories are modelled as public, so public_repo covers /repos/**); Stripe <resource>:read|write (:write ⇒ :read). Other twins: <entity>:read|write. "*" grants everything; bot tokens keep chat:write + files:write. No token → not_authed; an undeclared token → invalid_auth. |
rate_limits | enabled, rules keyed by operation id, vendor method (conversations.list), METHOD /path, a prefix* glob, a bare HTTP method, or * — most specific wins. Each rule: window_seconds (1–86400), max_requests, optional retry_after_seconds. The window slides per (environment, credential, rule); credential = the vendor token when presented, else the authenticated Blobfish key or environment token, else the browser’s owner cookie when it is the world’s recorded owner, else one shared anonymous bucket — a bearer or cookie that does not verify never earns its own window. Allowed calls carry x-blobfish-rate-limit: remaining/limit. |
clock | offset_seconds (±315360000s), or accumulate with POST …/policy/clock/advance {seconds}. Twins stamp writes from the epoch 2026-01-05T09:00:00Z plus one minute per row so scenarios replay identically; the offset shifts that epoch for the execution at hand (the stored world never changes). Shifted calls carry x-blobfish-clock-offset; GET …/policy reads the clock back. |
Boundaries, plainly: scopes gate the vendor-path REST surface, where a vendor credential is presented — MCP tools/callis authenticated by Blobfish and carries none, so it is subject to rate limits, the clock, and unmounted-hit accounting, not scopes; that holds on the per-service MCP endpoint and on the environment’s world-level links.mcpalike. Rate windows live in the serving process’s memory and reset on restart or redeploy. Unmounted hits are coalesced in memory and flushed within a few seconds (and before every read). The clock shifts the write stamps of mounted twins; it does not age seeded rows or expire anything. Neither models a vendor’s plan tier, monthly quota, or OAuth flow, and the per-operation route (services/<twin>/operations/<id>) and cluster (tenv_) environments do not read the policy.
Probe health
$ curl -sS -i "$BLOBFISH_BASE_URL/api/v1/twin/environments/twe_<id>/health" \
-H "X-API-Key: $BLOBFISH_API_KEY"- Website plane:the backing world’s SQLite database must be present, a live query must return the mounted tables, the seed rows must match the manifest, and a read tool must execute — the same runtime probe that gates “Sandbox ready” on every hosted world. Blank-harness environments are probed on their own hosted-world database.
- Cluster plane: the control-plane projection must report
ready, the lease must have time left, and every mounted provider must answer an MCPinitializeand serve a non-emptytools/listthrough the gateway proxy. - The response is
200only when every check passed, else503; either waychecks[]names each probe with its detail andlatency_msis the time the probe actually took. An unconfigured backend is a failedprojectioncheck with the reason — never anok.
Route an agent with it
Read services[].mcp_url once and hand each one to your MCP client with the X-API-Key header; inside a cluster environment’s agent pod use agent_loopback.endpoints instead — no credential is needed there. The local-testing guide shows the client configuration; custom scenarios shows how to start an environment from a preset before you route to it.