API reference · v1
BobSwap agent reference
The seed API, relay pairing protocol, and exact responsibilities of a BOB tab.
Public REST API for durable workspace seeds and a zero-storage browser-to-browser relay. API version: 1.0.0. This reference describes the current implementation; client responsibilities and unfinished features are marked explicitly.
Connection and authentication
Base origin: https://bob-snapshot-bridge.lovable.app. API prefix: /api/v1. Preview and published versions may differ until the latest changes are published. Use the origin hosting this document when testing a preview.
GET /api/health requires no key and returns {"ok":true,"version":"1.0.0"}.
Seed clients authenticate with Authorization: Bearer <API key>. Keys start with bsk_live_ followed by 32 lowercase hexadecimal characters. Email/password login is exclusively for the operator dashboard, not API clients. There is no public signup, account-management endpoint, or key-management endpoint. The dashboard and key-creation UI are not built yet; obtain an already-provisioned key from the operator. Never invent a key or embed one in a public bundle.
Scopes are cumulative:
read: read seeds, metadata, lists, and create relay offers.upload: all read operations plus seed PUT.manage: all upload operations plus delete seeds and create signed links.
Keys are account-bound for seed requests. Missing, invalid, expired, revoked, wrong-account, and insufficient-scope keys return 401 {"error":"key required"}. Stored hashes are verified with a constant-time comparison.
?key=<API key> is supported only on seed GET/HEAD requests. Any write containing that parameter is rejected with 403 {"error":"read-only scope in URL"}, even with a valid header. A URL key grants read-only access even if the underlying key has a higher scope. Prefer headers: URLs may enter browser history, copied links, or external logs. Pairing codes must never be placed in URLs.
CORS origin is *. Allowed methods advertised by preflight: GET, PUT, POST, DELETE, OPTIONS. Allowed headers: authorization, content-type, if-none-match. Exposed response headers: ETag, X-Seed-Version, Content-Length. HEAD works for archives and signed links, but is not advertised in the current preflight methods.
Archive and version rules
An archive is UTF-8 JSON, not a ZIP. Required fields:
{
"version": 1,
"createdAt": "2026-10-23T01:00:00Z",
"files": [
{ "path": "notes/hello.txt", "base64": "SGVsbG8K" }
]
}version is the archive format number, exactly 1; it is not the version label in the URL. createdAt must be a parseable timestamp. Each file needs a nonempty relative path and string base64. Absolute paths, backslashes, empty path segments, . and .. segments are rejected. Invalid archives return 400 naming the first invalid entry. The current server checks that base64 is a string, not that its contents decode correctly; clients must validate decoding themselves.
Account and seed names: 1–64 lowercase letters, digits, ., _, -. Version labels: 1–100 letters (either case), digits, ., _, -. latest, versions, meta, link are reserved labels.
Recommended label: <sha256-first-12-hex>-<YYYYMMDD>T<HHMMZ-or-HHMMSSZ>, for example 0123456789ab-20261023T010000Z. Compute SHA-256 over the exact UTF-8 archive bytes. When a label matches this format, a mismatched SHA prefix returns 409. Other valid labels such as v1 are allowed.
Maximum archive request body is 209715200 bytes (200 MiB), including JSON and base64 overhead. Hosting or storage may impose lower practical limits; archives near this maximum have not been verified. Preserve exact bytes for retries: even JSON whitespace changes the SHA and may cause a conflict.
Seed quick start
Set these shell variables before running the examples. KEY is a key issued by the operator; replace the marked value. This example archive is only a smoke-test file, not a full BOB workspace.
BASE='https://bob-snapshot-bridge.lovable.app'
ACCOUNT='personal'
SEED='demo'
VERSION='v1'
KEY='<operator-issued API key>'
cat > workspace.json <<'JSON'
{"version":1,"createdAt":"2026-10-23T01:00:00Z","files":[{"path":"notes/hello.txt","base64":"SGVsbG8K"}]}
JSON
curl -i -X PUT "$BASE/api/v1/$ACCOUNT/$SEED/$VERSION" \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
--data-binary @workspace.json
curl -f "$BASE/api/v1/$ACCOUNT/$SEED/latest" \
-H "Authorization: Bearer $KEY" \
-o downloaded-workspace.jsonPUT requires upload or manage scope. New bytes at a new version return 201. Identical re-PUT returns 200. Different bytes at the same version return 409; choose a new label, do not overwrite. Success body is {"url":"…","meta":{…}}.
Seed endpoints and caching
All paths below are relative to /api/v1 unless stated otherwise.
PUT /{account}/{seed}/{version}— upload raw JSON, upload scope. 201 new / 200 identical / 409 conflict.GET /{account}/{seed}/{version}— retrieve the byte-identical archive, read scope. HEAD returns headers without the archive body.GET /{account}/{seed}/{version}/meta— tiny metadata, read scope.GET /{account}/{seed}/versions— metadata array ordered by newestuploaded_at, read scope.GET /{account}/{seed}/latest— resolve the most recently uploaded version, not the greatest label or newestcreatedAt. IncludesX-Seed-Versionwith the actual label. HEAD supported.GET /{account}/{seed}/latest/meta— metadata for that resolved version.GET /{account}/seeds—{"account":"…","seeds":[{"name":"…","latest":"…","versions":[…]}]}, read scope.DELETE /{account}/{seed}/{version}— delete that version, manage scope, returns{"deleted":N}. Adding?purge_name=1deletes ALL versions under the seed name, not just the label in the path. Do not use it for routine single-version cleanup.
Metadata fields: version (format number 1), createdAt, fileCount, size (archive bytes), sha256, seed_id_and_version (URL version label), uploaded_at (server timestamp).
Archive responses use Content-Type: application/json, Cache-Control: private, must-revalidate, and an ETag of the quoted full SHA-256. Metadata also carries an ETag. Send the previously saved ETag with If-None-Match; unchanged content returns 304 with no body. Keep your local archive on 304. Do not parse a 304 as JSON. Deleting the current latest version causes /latest to resolve to the next newest upload.
curl -i "$BASE/api/v1/$ACCOUNT/$SEED/latest/meta" \
-H "Authorization: Bearer $KEY"
# Replace the marked digest with your saved response ETag.
curl -i "$BASE/api/v1/$ACCOUNT/$SEED/latest" \
-H "Authorization: Bearer $KEY" \
-H 'If-None-Match: "<previous full SHA-256>"'Signed share links
POST /{account}/{seed}/{version}/link requires manage scope. Optional JSON body: {"ttl_hours":24}. Default 24 hours; allowed range 0–720 hours. Zero means no expiry. Returns 201 with url and expires_at (null for no expiry).
curl -i -X POST "$BASE/api/v1/$ACCOUNT/$SEED/$VERSION/link" \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"ttl_hours":24}'The returned /g/{token} URL is an HMAC-signed download capability. GET/HEAD needs no API key. Treat the entire URL as a secret: anyone holding it can retrieve that one archive. It serves JSON, not an import UI. Expired or tampered links return 401 in the current implementation; a valid link whose archive has been deleted returns 404. Revoking a seed key does not revoke an already-issued signed link; expiry or deleting its target archive removes access.
Relay pairing API
Relay HTTP endpoints manage pairing metadata only. They NEVER accept workspace content or file chunks. Workspace bytes travel exclusively over an ephemeral realtime broadcast connection between clients. The service is a relay, not a direct peer-to-peer connection. The browser harness and production BOB client integration remain unfinished.
POST /relay/channels requires a header API key with read scope or higher. Required JSON fields:
{
"mode": "copy",
"size": 3145728,
"sha256": "<64 lowercase hex SHA-256 of exact archive bytes>",
"file_count": 200,
"owner_device": {
"name": "desktop",
"public_key": "<base64 SPKI DER of a P-256 public key>"
},
"recipient_hint": { "name": "phone" }
}Replace placeholders with real values. mode: copy or swap. size: positive integer, at most 209715200 bytes. file_count: integer 0–1000000. Device names: trimmed, 1–40 characters. recipient_hint is optional and is not an authorization rule. Current server validation checks plausible SPKI formatting, not full cryptographic validity; clients must import and verify the actual key.
Returns 201: channel_id, pairing_code (six digits as a string), expires_at, realtime_channel, owner_join_token. Default TTL is 15 minutes; server configuration may change it, up to 120 minutes. Trust expires_at.
POST /relay/channels/{channel_id}/join needs no API key. Send the code only in the JSON body:
{
"pairing_code": "<six-digit code from owner>",
"device": {
"name": "phone",
"public_key": "<recipient base64 SPKI public key>"
}
}Returns 200: recipient_join_token, realtime_channel, channel_id, mode, size, sha256, file_count, visual_check, owner_device. Optional join mode must match the offer; optional owner_join_token is used for self-join detection. Reusing the owner public key also fails.
Wrong code: 403 with attempts_left; the fifth wrong attempt destroys the channel and returns 410. Joining works exactly once; self-join, second join, or mode mismatch returns 409. Unknown or already deleted channels return 404. Expired channels are lazily deleted, so later requests may return 404 rather than 410.
For the following requests use your role's join token as the bearer credential, NOT the original API key:
GET /relay/channels/{channel_id}— live state including role, status, mode, size, sha256, file_count, expires_at, visual_check, owner_device, recipient_device, progress.POST /relay/channels/{channel_id}/status—{"status":"streaming","progress":{"n":1,"of":16}}; status accepts streaming, completed, failed, aborted. Optionalreasonhas at most 60 characters. Terminal states delete the row; the response containsdeleted:true.DELETE /relay/channels/{channel_id}— abort and delete the channel.
Channel GET/DELETE/status reject URL keys. Missing/invalid role tokens return uniform 404. Lifecycle: open → paired → streaming → deletion on completed/failed/aborted; five wrong codes or expiry also delete metadata. Expiry cleanup runs on relay requests with an hourly backstop. Client buffers must be discarded separately by clients.
Relay client contract
Use WebCrypto to generate an ECDSA P-256 identity. Public key format is base64 SPKI DER. Device ID is the first 16 lowercase hex characters of SHA-256 over decoded SPKI bytes.
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']
);
const spki = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
const publicKeyB64 = btoa(String.fromCharCode(...spki));
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', spki));
const deviceId = Array.from(digest, b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);Use the EXACT returned realtime_channel: rly:{channel_id}:{random}. Never reconstruct it from the channel ID or expose it publicly. The random suffix is the subscription capability. Realtime topics currently use public broadcast; HTTP join tokens authorize control-plane requests, not private realtime subscriptions. Role/direction checks and any cryptographic message verification are client responsibilities. Do not claim authenticated private subscriptions or end-to-end encryption.
The client contract uses broadcast event relay, with the following payloads:
{"t":"chunk","i":0,"of":16,"b64":"<base64 chunk>"}
{"t":"ack","i":0}
{"t":"complete","sha256_ok":true}
{"t":"abort","reason":"integrity"}Split exact UTF-8 archive bytes into 196608-byte chunks (192 KiB), yielding about 256 KiB base64 per chunk. Wait for ACK, retry missing chunks at most twice, then abort. Reassemble in memory and verify SHA-256 against the offer before importing anything. No file chunk is posted to the REST API. Broadcast is transient, not a durable delivery queue. Clients must subscribe successfully before streaming and handle duplicates/retransmits.
Visual check: first four uppercase hex characters of SHA-256 over the UTF-8 string rv|{owner_public_key_b64}|{recipient_public_key_b64}|{size}. Both screens must show the same check before proceeding. It is a visual pairing check, not encryption.
Copy changes no ownership. Swap, AFTER integrity verification, requires recipient transfer_request, explicit human acceptance on the owner's device, and owner transfer_grant containing grant_sig, to_device_id, ts, channel_ref. Sign with ECDSA P-256 / SHA-256 over exact UTF-8 bytes bobswap-grant|{to_device_id}|{channel_id}|{visual_check}|{ts}. Recipient verifies against the owner public key before any ownership changes. Store ownership/release receipts ONLY on the devices, never in the service. These client steps are a contract, not enforced by HTTP status updates; the two-tab harness has not yet verified them.
On cancellation, integrity failure, or a connection drop exceeding 20 seconds, clients must abort the channel and clear all buffers. The current control plane cannot detect a closed tab by itself. After verified reception, import locally (covered paths overwrite, other paths stay), set freshness from createdAt, and reload the recipient tab. Reload the owner only after a swap. There is no server OPFS import endpoint.
Errors and operational limits
- 400: malformed input or archive; correct the first reported error.
- 401: invalid seed credential/scope or invalid signed link.
- 403: URL key on writes or incorrect pairing code.
- 404: missing seed/channel, deleted channel, or invalid channel role token.
- 409: version conflict, SHA-prefix mismatch, reserved version, or invalid pairing state.
- 410: channel burned or expired when detected before deletion.
- 413: archive too large.
- 429: rate limited; respect
Retry-After: 60and back off with jitter. - 500: storage/control-plane failure; retry carefully without changing archive bytes.
- 503: relay offers paused for maintenance.
Rate limits are best-effort in-memory per worker isolate, NOT globally shared quotas: 120 authenticated requests per key/minute, 240 per IP/minute, 20 failed auth attempts per IP/minute, 60 signed-link downloads per IP/minute, 10 failed relay joins per IP/minute. Five wrong codes burn an individual channel regardless of the IP bucket.
Previously tested: seed CRUD/auth/cache/link flows and about 5 MiB relayed between simulated clients. Not yet verified: near-200-MiB transfers, browser harness disconnect handling, and the end-to-end ownership swap confirmation/signature/receipt flow. Realtime delivery has no application-level encryption added by this service. Never log archives, codes, keys, role tokens, or capability topics.