INFINITEOCEAN.IO — API SPEC FOR AGENTS ======================================= Base URL: https://api.infiniteocean.io CORS: * (call from anywhere) Auth: X-Ocean-Key header = your public key. Required for all routes. Secure your routes with share/unshare (or grant/revoke). Fifty-eight endpoints + access control + view subdomain + built-in MCP server.

Drops-first

All features are accessible via POST /exec using the Drops language. HTTP endpoints still work but Drops is the recommended interface. See /drops for the full language reference.

This spec page documents both Drops commands and the remaining HTTP-only endpoints.

1. save — Save, get, update, remove ──────────────────────────────────
-- Save an event (no key = append-only)
save myapp/events/signup action: "click"

-- Save an entity (with key)
save users @user-123 name: "Alice" email: "alice@example.com"

-- Get it back
users @user-123

-- Update (partial merge)
update products @sku-42 price: 29.99 on_sale: true

-- Remove it
remove users @user-123
Route rules: - Segments: a-z A-Z 0-9 - _ . - No wildcards, no leading/trailing slashes, no empty segments - 1-10 segments, max 512 chars - Example: deploy/myapp/prod Entity key rules (optional): - Characters: a-z A-Z 0-9 - _ . - Max 256 characters. No slashes. - Same route + key = versions of one entity. Latest write wins. - payload: null with a key = deleted (entity is removed). CRUD: SAVE: save users @user-123 name: "Alice" GET: users @user-123 UPDATE: update users @user-123 email: "new@x.com" REMOVE: remove users @user-123 LIST: users Note: JSON object syntax { "key": "value" } still works (backwards compatible). Aliases: write = save, read = get, delete = remove, patch = update, query = find 2. GET /stream/:prefix — Replay history + realtime via SSE ────────────────────────────────────────────────────────── Request: GET https://api.infiniteocean.io/stream/myapp/*?last=100 Prefix examples: /stream/myapp/* → everything under myapp/ /stream/myapp/events/* → all myapp events /stream/myapp/events/signup → exact route only /stream/* → everything (use with caution) Query params: last=N last N matching drops, then go live (most common) from=0 replay all history, then go live from= replay from timestamp, then go live from=latest live only, no replay (default) limit=N close after N live drops (replay is not counted — all history replays first, then limit counts only newly-arriving drops) SSE mode (default): Returns text/event-stream. Each drop is a `data:` line (JSON). After replay, sends `event: caught-up` with replayed count. Then live drops arrive as they're posted. data: {"drop_id":"...","route":"...","source":"...","payload":"...","created_at":"..."} data: {"drop_id":"...","route":"...","source":"...","payload":"...","created_at":"..."} event: caught-up data: {"timestamp":"...","replayed":2} data: {"drop_id":"...","route":"...","payload":"...","created_at":"..."} JSON pull mode (send Accept: application/json): Returns JSON object with drops array, cursor, and has_more boolean. No live streaming — connection closes after response. { "drops": [...], "cursor": "2026-02-06T12:00:03.000Z", "has_more": true } 3. search — Vector, text, and hybrid search ────────────────────────────────────────────
-- Text search (keyword + fuzzy matching)
search myapp/logs "deployment error" top 10

-- Vector search (send vector via POST /exec body)
search vectors/memory top 10

-- Hybrid search (text + vector combined)
search vectors/memory "hello world" top 10
Three modes: - Vector only: send "vector" → vector search (cosine similarity) - Text only: send "text" → keyword search with fuzzy matching - Hybrid: send both → merged results using selected fusion strategy Fusion strategies (hybrid mode only): - "weighted" (default) — normalize both score sets to [0,1], combine as 0.5 × vector + 0.5 × text - "rrf" — Reciprocal Rank Fusion: score = Σ 1/(60 + rank). Ignores raw scores, uses rank position only. More robust when score distributions differ. Industry standard for RAG. Response metadata: "searched" — number of vectors/documents actually compared during search "total_vectors" — total indexed vectors at this prefix (vector/hybrid modes only) "index_type" — "hnsw" (graph index, ≥500 vectors) or "brute_force" (<500 vectors) "dimensions" — vector dimensionality (vector/hybrid modes only) "time_ms" — search duration in milliseconds "mode" — "vector", "text", or "hybrid" "fusion" — "weighted" or "rrf" (hybrid mode only) Vector indexing: Prefixes with fewer than 500 vectors use exact brute-force search (100% recall). At 500+ vectors, an HNSW graph index is built automatically for O(log n) search with 95–99% recall. The index is persisted to disk — subsequent searches on the same prefix warm instantly from the saved index instead of re-scanning all drops. Compute cost is based on "searched" (vectors visited), not total vectors. Text search details: - Extracts all string values from drop payloads (including entity keys) - Keyword ranking with fuzzy matching - Fuzzy matching: misspelled words (edit distance ≤ 1) still match with slight score penalty - Skips "vector" fields in payloads (numeric arrays, not text) - Text index is also persisted to disk for instant warm on subsequent searches Filtered search: Add "filter" to narrow search results by metadata. Same MongoDB-style operators as query ($eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $contains, $startsWith, $endsWith). Filter is applied after search scoring — results are ranked first, then filtered. Use "candidates" (default = top_k) to search for more results before filtering. Vector drop convention — write a drop with a "vector" field in payload: save vectors/docs vector: (0.12, -0.34, 0.56, ...) text: "hello" metadata: {} Any dimensionality works (128, 384, 768, 1536, 3072). All vectors in a prefix must have the same dimensions. Non-vector drops at the same prefix are skipped. 4. find — Filter, aggregate, and join ─────────────────────────────────────────────
-- Filter by field (bare field names, single = for equality)
find myapp/events where type = "signup" sort created_at desc limit 10

-- Implicit find (route + where)
users where age > 10 sort name limit 5

-- List all current entities
find users latest

-- Aggregation
find myapp/orders where status = "completed" group category sum amount count
Entity key deduplication (latest: true): When latest is set, drops are grouped by route+key. Only the newest drop per key is kept. Deleted entities (payload: null) are excluded. Non-keyed drops always included. Filters are applied after deduplication — you query current entity state, not old versions. Filter operators: $eq, $ne equality / inequality $gt, $gte, $lt, $lte numeric/string comparison $in, $nin value in / not in array $exists field exists (true) or missing (false) $contains string contains substring $startsWith string starts with prefix $endsWith string ends with suffix Field paths use dot notation: "payload.metadata.source" resolves nested fields. JSON string payloads are auto-parsed before filtering. Aggregation: Compute functions: $sum, $avg, $min, $max, $count, $first, $last, $distinct $count takes true as its value (not a field path). $distinct returns an array of unique values. group_by can be a string or array of strings. Omit group_by to aggregate all matching drops. Join — combine drops from two prefixes: Left join: every drop gets a _joined object with the matched fields. Drops without a match are still included (without _joined). join.on is [localField, remoteField]. join.select picks which fields from the remote drop. 5. get — Read a single entity ───────────────────────────────
-- Get the latest version of an entity
users @user-123

-- Or explicitly:
get users @user-123
404 NOT_FOUND if the entity was never created. 404 DELETED if the latest version is deleted (payload: null). Payload auto-parsing: Payloads stored as valid JSON strings are automatically parsed into objects on retrieval. Always check typeof payload before calling JSON.parse() — it may already be an object. Version history: Via HTTP: GET /entity/users?key=user-123&history=true → list all versions Via HTTP: GET /entity/users?key=user-123&at= → specific historical version 6. Query, Aggregate & Define Schemas ─────────────────────────────
-- Find with filters
find users where status = "active" sort name limit 10

-- Pick specific fields
find users where status = "active" then pick name, email

-- Group and aggregate
find orders group category sum amount count

-- Combine routes
find orders join users on user_id = key then pick *, name

-- Sort and paginate
find users sort name limit 10 skip 20

-- Atomic counter
add 1 to pages @home views

-- Atomic batch
bulk (save accounts @alice balance: 900, save accounts @bob balance: 1100)
Field mapping (automatic): key, created_at, drop_id, route, source → direct fields (no prefix) everything else → payload.{field} Supported queries: find route where field = "value" find route where field > 10 and field2 contains "text" find route where field in ("a", "b", "c") find route where field exists find route where field starts with "A" find route sort field limit 10 skip 5 find route group field sum amount count find route join route2 on local_field = remote_field find route then pick field1, field2 count route count route where field > 10 Filter operators: =, !=, >, <, >=, <=, in, not in, contains, starts with, exists Aggregate functions: sum, avg, min, max, count, first, last, distinct Saving without a key auto-generates a UUID key. Queries return current entity state (latest version, not history). Syntax notes: - `then` replaces `|` for pipes (e.g. find users then pick name). `|` still works. - `pick` replaces `pluck` (e.g. pick name, email). `pluck` still works. - `skip` replaces `offset` (e.g. limit 10 skip 5). `offset` still works. - `()` replaces `[]` for arrays (e.g. bulk (save ..., save ...)). `[]` still works. - Bare field names replace `.field` (e.g. pick name not pick .name). Dot prefix still works. - Key:value pairs replace `{}` in save/update (e.g. save users @id name: "Alice"). JSON `{}` still works. Schemas: define "route" fields name: "text required" email: "text" age: "number" Supported types: text, number, boolean Constraints: required Validation runs on save and update. Atomic batches: bulk (save a @x ..., save b @y ...) All writes succeed or none do. 7. Access Control — Route zones and trust drops ───────────────────────────────────────────────── Routes are organized into zones based on the first segment. Access is enforced on all operations (save, get, search, find). Zones: Zone First segment Read access Write access ──── ───────────── ─────────── ──────────── open anything else anyone (or ACL-restricted) anyone (or ACL-restricted) internal _ (single underscore) anyone X-Ocean-Key + entity ownership private matching X-Ocean-Key matching X-Ocean-Key shared shared granted key + permission granted key + 'drop' permission trust trust denied (system-managed) owner only (first writer) system __ (double underscore) denied internal only Open zone (default, backward-compatible): Open-zone routes are accessible without a key for quick prototyping. Secure with Route ACLs before production (see below). Drops auto-protects routes on first write. Route ACLs (open-zone access control): Any open-zone route can be locked down by writing a _access entity.
-- Control access to a route
share agency/shop with @DEV_KEY @CLIENT_KEY

-- Remove access
unshare agency/shop from @CLIENT_KEY
Modes: shared: Both reads and writes require a key in the grant list + matching permission. protected: Reads are public (no key needed). Writes require a key in the grant list + "drop" permission. writeonly: Writes are public (anyone can write). Reads are blocked (403) for non-grant keys. moderated: Reads are public. Writes require authentication (any key). Deletes restricted to grant-list keys. Hierarchy: ACLs inherit down the route tree. When checking agency/shop/products: 1. Check agency/shop/products for _access entity 2. Check agency/shop for _access entity 3. Check agency for _access entity First match wins (most specific ACL takes precedence). Bootstrap: First write of _access to an open route always succeeds (no ACL exists yet). The writer's key is auto-injected as owner. Only the owner can modify _access afterward. Cache: ACL lookups are cached for 30 seconds. Changes take effect within 30s. Internal zone: Routes starting with _ (single underscore): _site, _memory, _cron, _mcp, _config, _handles, _dlq, etc. Publicly readable — anyone can view _site pages, search _memory, etc. Writes require X-Ocean-Key header. First writer to a route+key pair becomes the entity owner. Only the owner (or admin keys) can update that entity. This prevents overwriting others' data. Private zone: The first segment IS your public key (base64url, 43+ chars). Only requests with matching X-Ocean-Key header can read or write. Shared zone: Routes starting with "shared/" are governed by trust drops. A trust drop at trust/{name} with key "acl" defines who can access shared/{name}/*. Trust zone: Routes starting with "trust/" are system-managed. Direct reads are always denied. First write to trust/{name} (key=acl) establishes ownership. Only the owner can update the trust drop. Header: X-Ocean-Key: Required for private, shared, trust, and internal zone writes. Required for open zone routes with a _access ACL (shared mode). Optional otherwise. Key format: Ed25519 public keys are base64url-encoded (no padding). 43 characters. Characters: A-Z a-z 0-9 _ - 8. Key Ownership — Claim, transfer, and manage keys ───────────────────────────────────────────────────── Claim ownership of any key (device, agent, node) from your personal key. Claiming auto-grants you full access to the claimed key's private data via the existing grants system. The claimed key keeps its own access unchanged. POST /key/claim — Claim a key: POST https://api.infiniteocean.io/key/claim Content-Type: application/json X-Ocean-Key: { "key": "", (required — the key to claim) "label": "Living room camera", (optional — human label) "type": "device" (optional — device, agent, node, etc.) } Response (200): { "ok": true, "key": "", "label": "Living room camera", "type": "device" } Rules: - Cannot claim your own key (403) - Cannot claim a key already owned by someone else (409) - Re-claiming your own key is idempotent (200, updates label/type) - Claiming a key that doesn't exist yet is allowed (device may come online later) GET /key/claimed — List all keys you own: GET https://api.infiniteocean.io/key/claimed X-Ocean-Key: POST /key/transfer — Transfer ownership to another key: POST https://api.infiniteocean.io/key/transfer Content-Type: application/json X-Ocean-Key: { "key": "", (required — the claimed key to transfer) "to": "" (required — the new owner) } DELETE /key/claim/:key — Release ownership: DELETE https://api.infiniteocean.io/key/claim/ X-Ocean-Key: GET /key/owner/:key — Check who owns a key: GET https://api.infiniteocean.io/key/owner/ 9. POST /blob — Upload binary media ──────────────────────────────────── Upload images, video, audio, or any binary file. Served with Range support for video/audio seeking. Each blob gets a metadata drop with a _blob payload field, so blobs are queryable like any other drop. Request: POST https://api.infiniteocean.io/blob Content-Type: video/mp4 (MIME type of the file) X-Blob-Route: myapp/videos (required — route for the metadata drop) X-Ocean-Key: (optional — for non-open routes) Body: raw binary data Max single-request size: 100 MB. For larger files, use chunked uploads (see below). IO cost: 1 per 100 KB of upload size (minimum 2). Supported content types: image/*, video/*, audio/*, application/octet-stream. 10. GET /blob/:id — Stream binary media ─────────────────────────────────────── Returns the binary file with original Content-Type. Supports Range requests for seeking in video/audio players. Request: GET https://api.infiniteocean.io/blob/a1b2c3d4-... Optional header: Range: bytes=0-1023 (for partial content / seeking) Response (200 for full, 206 for partial): Content-Type: video/mp4 Accept-Ranges: bytes Cache-Control: public, max-age=31536000, immutable Usage in HTML: 11. Chunked uploads — Resumable upload for any file size ───────────────────────────────────────────────────────── Upload files of any size in 10 MB chunks. Resumable — if a connection drops, check progress and continue from where you left off. Step 1 — Start: POST https://api.infiniteocean.io/upload/start Content-Type: application/json X-Ocean-Key: { "route": "myapp/videos", "type": "video/mp4", "size": 524288000, "name": "big-video.mp4" } Step 2 — Upload each part: PUT https://api.infiniteocean.io/upload//part/1 Content-Type: application/octet-stream Body: raw binary chunk (up to 10 MB) Step 3 — Check progress (for resume): GET https://api.infiniteocean.io/upload/ Step 4 — Complete: POST https://api.infiniteocean.io/upload//complete Upload sessions expire after 24 hours if not completed. 12. POST /auth/login — Magic link sign-in ────────────────────────────────────────── Send a magic link to an email address. If the email is new, an Ocean key is generated and associated with it. Returns { sent: true } — never reveals the key. Request: POST https://api.infiniteocean.io/auth/login Content-Type: application/json { "email": "alice@example.com", "callback_url": "https://yourapp.com/auth/callback", "app_name": "MyApp", "subject": "Sign in to MyApp" } Response (200): { "sent": true } Rate limit: 5 login requests per email per hour. 13. GET /auth/verify — Verify magic link token ──────────────────────────────────────────────── Verify a magic link token and get the Ocean key. Token is single-use and expires after 15 minutes. Request: GET https://api.infiniteocean.io/auth/verify?token= Response (200): { "key": "base64url-ocean-key" } 14. GET /auth/me — Get authenticated user info ──────────────────────────────────────────────── Request: GET https://api.infiniteocean.io/auth/me X-Ocean-Key: Response (200): { "email": "alice@example.com", "key": "base64url-ocean-key", "tier": "authenticated", "balance": 0 } 15. POST /auth/resolve — Probe email for existing account ──────────────────────────────────────────────────────────── Probes whether an account exists for the given email. Returns the public_id when one does. The Ocean Key is NEVER returned by this endpoint — keys are only obtainable via POST /auth/login → magic-link → GET /auth/verify, which proves email ownership. /auth/resolve does not create accounts or send mail and is safe to call without authentication. Request: POST https://api.infiniteocean.io/auth/resolve Content-Type: application/json { "email": "alice@example.com" } Response (200 — account exists): { "is_new": false, "public_id": "a1b2c3d4e5f6" } Response (200 — no account for this email): { "is_new": true } To obtain a key for an email, use POST /auth/login (sends a magic link to that mailbox) then GET /auth/verify?token=… (returns the key after the link is clicked). 16. on — Webhooks (server-side event reactions) ────────────────────────────────────────────────
-- React when data changes
when myapp/orders changes call "https://your-server.com/webhook"

-- List webhooks
webhooks

-- Remove a webhook
webhook delete "hook-uuid"
Webhook delivery: When a drop is written matching a hook's prefix, the drop is POSTed to the URL. Headers: Content-Type: application/json X-Ocean-Hook-Id: X-Ocean-Signature: hmac-sha256= (only if hook has a secret) Body: the full drop object (JSON) Signature = HMAC-SHA256(secret, raw JSON body) 17. Atomic Counters — Race-free increments ─────────────────────────────────────────── Add or subtract from any field atomically. No race conditions. Counter values are automatically merged into entity reads.
-- Add to a counter
add 1 to pages @home views

-- Add a larger amount
add 10 to stats @daily visits

-- Subtract
subtract 2 from inventory @widget stock
Pure counter updates are extremely fast (<1ms). Counter values are merged into entity reads automatically. 18. Secondary Indexes — Fast entity lookups ─────────────────────────────────────────── Entity lookups use indexes for fast reads. Entity indexes are automatic. Field indexes are explicit.

Indexes are created automatically on frequently-queried fields. Queries filtering on indexed fields are accelerated.

19. GET /health ────────────── { "status": "ok", "active_connections": 456 } 20. VIEW — Serve drops as web pages ──────────────────────────────────── Base URL: https://view.infiniteocean.io Drops become web pages. Write HTML (or CSS, JS, images) to a drop with an entity key, then view it at a clean URL. Update the drop, the URL serves the new version instantly. URL convention: https://view.infiniteocean.io/{route}/{key} https://view.infiniteocean.io/{route}/ ← key defaults to "index" Examples: /mysite/ → route=mysite, key=index /mysite/about → route=mysite, key=about /sites/blog/style.css → route=sites/blog, key=style.css Content-Type detection: 1. payload._content_type (explicit, highest priority) 2. Key extension (.css → text/css, .js → application/javascript, .png → image/png, etc.) 3. Default: text/html; charset=utf-8 Payload conventions: - String payload → served directly as body - Object with _body field → _body is served (allows _content_type alongside) - Object without _body → served as JSON Access control: - Open routes are viewable by anyone - Private/shared routes: pass ?_key= or X-Ocean-Key header - Priced drops: 402 Payment Required until purchased - System routes (__*) are blocked Example — publish an HTML page: save mysite @index "

Hello

" save mysite @style.css "body { font-family: system-ui; color: #333; }" Methods: GET, HEAD only. All other methods return 405. Security headers (automatic on all view responses): Cache-Control: public, no-cache always revalidates, updates visible immediately Referrer-Policy: strict-origin-when-cross-origin X-Frame-Options: SAMEORIGIN prevents clickjacking X-Content-Type-Options: nosniff Per-tenant Content Security Policy (CSP): CSP is NOT set by default (multi-tenant platform, a global CSP breaks tenant sites). To add CSP to your site, save a _config entity on your view route: save mysite @_config csp: "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src * data: blob:; connect-src 'self' https://api.infiniteocean.io" The Content-Security-Policy header appears on all pages under that route. Remove it by tombstoning: save mysite @_config null Custom domains: Register your own domain to serve drops at a clean URL with auto-provisioned TLS. Steps: 1. POST /domains with { "domain": "mycoolsite.com", "route": "mysite" } 2. Add a CNAME DNS record: mycoolsite.com → view.infiniteocean.io 3. Visit https://mycoolsite.com/ — TLS is provisioned automatically on first request 21–23. Domains — Register, list, and remove custom domains ─────────────────────────────────────────────────────────── POST /domains — Register a custom domain: POST https://api.infiniteocean.io/domains Content-Type: application/json X-Ocean-Key: { "domain": "mycoolsite.com", "route": "mysite" } GET /domains — List your domains: GET https://api.infiniteocean.io/domains X-Ocean-Key: DELETE /domains/:domain — Remove a domain: DELETE https://api.infiniteocean.io/domains/mycoolsite.com X-Ocean-Key: 24. bulk — Bulk write multiple drops ─────────────────────────────────────
-- Write up to 1000 drops atomically
bulk (
  save myapp/data @a n: 1,
  save myapp/data @b n: 2,
  save other/route "text"
)
All drops succeed or none do. Max 1000 drops per batch. Each drop validated individually. Access control checked per unique route. Webhooks fire for each drop (non-blocking). SSE subscribers receive each drop. 25. GET /export/:prefix — Export drops in any format ──────────────────────────────────────────────────── Export all drops under a prefix. Default NDJSON, or specify format for CSV, SQL, or MongoDB. Request: GET https://api.infiniteocean.io/export/myapp/data X-Ocean-Key: Query params: latest=true deduplicate by entity key (current state only) from= only drops after this timestamp filter= URL-encoded JSON filter object (same syntax as query) select=a,b,c comma-separated field projection format=csv export as CSV (Content-Type: text/csv) format=sql export as SQL (Content-Type: application/sql) format=mongo export as MongoDB script (Content-Type: application/javascript) dialect=mysql use MySQL syntax instead of PostgreSQL (with format=sql) Drops syntax: export users → JSON (default) export users as csv → CSV export users as sql → PostgreSQL CREATE TABLE + INSERTs export users as sql dialect mysql → MySQL variant export users as mongo → MongoDB insertMany() script Response (200): Default (NDJSON): application/x-ndjson — one JSON object per line CSV: text/csv — header row + data rows, nested fields flattened with dot notation SQL: application/sql — CREATE TABLE (types inferred) + INSERT INTO statements MongoDB: application/javascript — db.collection.insertMany([...]) script Example SQL output: CREATE TABLE IF NOT EXISTS "users" ( "_key" TEXT PRIMARY KEY, "_created_at" TIMESTAMPTZ, "name" TEXT, "role" TEXT, "active" BOOLEAN ); INSERT INTO "users" ("_key", "_created_at", "name", "role", "active") VALUES ('alice', '2026-01-01T12:00:00Z', 'Alice', 'admin', TRUE); 25b. translate — Convert Drops commands to SQL or curl ────────────────────────────────────────────────────── Translate any Drops command into equivalent SQL, curl, or raw REST syntax. Your logic is never locked in — see exactly how it maps to standard tools.
-- Query → SQL
translate "find users where .role = 'admin' sort .name limit 10" to sql
→ SELECT * FROM "users" WHERE "role" = 'admin' ORDER BY "name" LIMIT 10;

-- Write → SQL (upsert)
translate "save users @alice name: 'Alice'" to sql
→ INSERT INTO "users" ... ON CONFLICT ("_key") DO UPDATE SET ...;

-- Delete → SQL
translate "remove users @alice" to sql
→ DELETE FROM "users" WHERE "_key" = 'alice';

-- Any command → curl
translate "find users where .active = true" to curl
→ curl -X POST https://api.infiniteocean.io/query ...

-- MySQL dialect
translate "find users where .role = 'admin'" to mysql
→ SELECT * FROM `users` WHERE `role` = 'admin';
Targets: sql (PostgreSQL), mysql, curl, rest Commands: find/query, save/write, remove/delete, get/read, search, count 26. Computed Views ─────────────────────────────────────

Saved queries that expand on read. Create a view once, then query it like any route.

-- Create a view (saved query)
find users where status = "active" as active_users

-- Query through the view
find active_users where age > 25

When you query a view, the saved filters are merged with your new filters automatically.

27. run — Serverless Python execution ──────────────────────────────────────
-- Store a function
save fn/hello @main "def main(ocean, request):\n  return {'hello': request.get('name', 'world')}"

-- Run a function (sync)
run fn/hello name: "Ocean"

-- Run async (returns job_id)
run fn/process data: "..." async
Function requirements: - Payload must be a Python string stored at key "main" - Code must define a callable main(ocean, request) - request is the JSON body from the run command - Return value must be JSON-serializable (dict, list, string, number, bool, None) - print() output is captured in the "stdout" field Ocean client methods (available as first argument): ocean.read(route, key) read route @key ocean.write(route, payload, key=None) write route @key { payload } ocean.query(prefix, filter=None, sort=None, limit=None) query route where ... ocean.search(prefix, text=None, vector=None, top_k=10) search route "text" top N ocean.sql(query) find route where ... ocean.run(route, body=None) run route { body } ocean.upload(route, data, filename) POST /upload/{route} All Ocean client calls use the caller's X-Ocean-Key — normal access control applies. Function isolation (_config entity): Store a _config entity alongside your function to enable code privacy and data isolation. The server auto-injects app_key (your X-Ocean-Key) — it cannot be forged. Fields: execute — "public" = anyone can run (bypasses access check). Omit = default. scopes — Route prefixes the caller's Ocean client can access inside the function. Omit = caller key gets full access. app_key — Auto-injected by server (your X-Ocean-Key). Cannot be set manually. Public functions (execute:"public" + app_key): When a function has execute:"public", anyone can call it — even without an X-Ocean-Key header. The developer's app_key is used for compute billing. Ideal for public APIs, webhooks, and AI agent tools. Two-key model (when _config has app_key): Inside the function, `ocean` uses the developer's key (app state). `ocean.user` uses the caller's key, scoped to declared prefixes. Scoped tokens: When scopes are declared, the caller's key is replaced with a temporary token that only works for the declared route prefixes. Accessing routes outside the scopes returns 403 SCOPE_DENIED. Limits (sync mode): - 10 second timeout (408 EXECUTION_ERROR on timeout) - 256KB max code size - 1MB max stdout capture - Python standard library only (no pip packages) Limits (async mode): - Safety wall-clock: 24 hours Cost: 1 + ceil(ms / 1000) io. Free on your own node. Async retries: &max_retries=N (0-10, default 0). On failure, the job is automatically retried with exponential backoff (10s, 40s, 2m40s, 10m40s, up to 30m cap). No additional io is charged for retries. When all retries are exhausted, the job is marked "abandoned" and a DLQ drop is written to _dlq/runs with the job ID as key. 28. status — Async job status ─────────────────────────────
-- Check status of an async job
status "abc-123"
Statuses: running — function is executing paused — io balance empty, function paused (resumes when balance is topped up) done — function completed successfully failed — function failed (error field has details) retrying — function failed, waiting for retry (retry_at field has timestamp) abandoned — all retries exhausted, DLQ drop written (dlq: true) Jobs expire after 24 hours. 28b. POST /run/retry/:id — Manually retry a failed job ─────────────────────────────────────────────────────── Retry a failed or abandoned async job. Only the original caller can retry. Charges 1 io for the new execution attempt. Request: POST https://api.infiniteocean.io/run/retry/ X-Ocean-Key: (must match original caller_key) Response: { "job_id": "abc-123", "status": "retrying", "attempt": 4 } 29. POST /mcp/:namespace — MCP Server (JSON-RPC 2.0) ───────────────────────────────────────────────────── Every InfiniteOcean namespace can be an MCP (Model Context Protocol) server. Define tools as IO functions, connect any MCP client (Claude Desktop, Cursor, etc.). Endpoint: POST https://api.infiniteocean.io/mcp/:namespace Content-Type: application/json Setup: 1. Create tool functions as regular IO function drops (key "main") 2. Create a config entity at _mcp/servers with the namespace as key Config entity structure: save _mcp/servers @my-server { "name": "My MCP Server", "version": "1.0.0", "app_key": "YOUR_OCEAN_KEY", "tools": { "tool-name": { "description": "What the tool does", "inputSchema": { "type": "object", "properties": {} }, "function": "route/to/function" } } } (JSON syntax still works for complex nested objects) MCP methods supported: initialize → returns protocolVersion, serverInfo, capabilities notifications/initialized → acknowledged (no response) tools/list → returns array of available tools tools/call → executes a tool function via runner ping → returns {} Transport: Both Streamable HTTP and legacy SSE are supported. MCP client config (Claude Desktop, Cursor): { "mcpServers": { "ocean": { "url": "https://api.infiniteocean.io/mcp/ocean", "headers": { "X-Ocean-Key": "YOUR_KEY" } } } } MCP client config (VSCode / GitHub Copilot — .vscode/mcp.json): { "servers": { "ocean": { "type": "sse", "url": "https://api.infiniteocean.io/mcp/ocean?key=YOUR_KEY" } } } Claude.ai custom connector URL: https://api.infiniteocean.io/mcp/ocean?key=YOUR_KEY 30. POST /mcp/ocean — Built-in MCP Server ────────────────────────────────────────── A built-in MCP server that gives any MCP client full access to the InfiniteOcean API. No setup needed — just connect and authenticate with your Ocean Key. Endpoint: POST https://api.infiniteocean.io/mcp/ocean Content-Type: application/json X-Ocean-Key: (required) Tools (22): get_context, read, write, delete, patch, bulk_write, search, sql, query, list, history, remember, recall, run, run_status, fetch_url, cron_create, cron_list, cron_delete, webhook_create, webhook_list, webhook_delete Auth: X-Ocean-Key header or ?key= query parameter is required. 31–33. cron — Scheduled jobs ─────────────────────────────
-- Run daily at 9am
every day at 9am daily-report: run fn/report

-- List schedules
schedules

-- Remove a schedule
schedule delete "daily-report"
Schedule examples: * * * * * every minute */5 * * * * every 5 minutes 0 * * * * every hour 0 9 * * * daily at 9am 0 9 * * 1 Mondays at 9am 0 0 1 * * first of every month Fields: name — alphanumeric with - or _ (unique job name) route — function route to execute (must have key "main") schedule — standard 5-field cron: minute hour day month weekday body — optional JSON passed as `request` to the function enabled — default true, set false to pause max_retries — 0-5, default 0. On failure, retry with backoff. On exhaustion, DLQ drop written. 33. POST /webhook/:name — Inbound webhook endpoint for integrations ────────────────────────────────────────────────────────────────── Generic inbound endpoint for external services (Stripe, GitHub, Slack, etc.). No Ocean key required — the integration owner's io balance is charged. The raw event is captured as a drop with full headers for signature verification. Setup — Register the integration first: save _integrations @my-stripe type: "stripe" active: true Request — External service sends events here: POST https://api.infiniteocean.io/webhook/my-stripe Content-Type: application/json What happens: 1. Looks up "my-stripe" in _integrations entities 2. Writes are free — no io charged 3. Writes a drop to _webhooks/my-stripe with payload containing headers, body, received_at 4. Returns 200 immediately 5. Outbound webhooks matching _webhooks/my-stripe fire (non-blocking) Wiring a handler function: 1. Store a function: save fn/stripe-handler @main "def main(ocean, request): ..." 2. Register outbound webhook: on write _webhooks/my-stripe call "https://api.infiniteocean.io/run/fn/stripe-handler" 3. The handler receives the drop, verifies signatures, writes semantic drops 34. GET /whoami — Account info ──────────────────────────── Returns your account details, balance, and identity information. Request: GET https://api.infiniteocean.io/whoami X-Ocean-Key: Response (200): { "key": "base64url-ocean-key", "tier": "balance", "ip": "1.2.3.4", "public_id": "a1b2c3d4e5f6", "handle": "@yourname", "email": "you@example.com", "balance": 950 } 35. POST /io/send — Send io to another user ──────────────────────────────────────────── Transfer io from your balance to another user by @handle, email, or public_id. Request: POST https://api.infiniteocean.io/io/send Content-Type: application/json X-Ocean-Key: { "to": "@handle", "amount": 100 } Response (200): { "ok": true, "sent": 100, "to": { "public_id": "a1b2c3d4e5f6", "handle": "@bob" }, "remaining": 850 } 36. Compute & io ───────────────── All compute is free — reads, writes, search, SQL, functions. No per-request charges. Every node on the network contributes compute automatically (distributed compute mesh). io is InfiniteOcean's currency for content monetization. 5,000 io = $1 USD. Buy at infiniteocean.io/dashboard or receive from other users via POST /io/send. Custom function pricing: Set a price on your function by adding a price field to its _config entity. When someone runs your function, they are charged upfront — 99% goes to your app_key, 1% to the platform. Paid drops (price gating): Add a price field when writing a drop to gate access behind a purchase. Non-purchasers see metadata but payload is redacted. save myapp/premium @article-1 content: "full content" price: 5 37. POST /purchase/:id — Purchase a priced drop ───────────────────────────────────────────────── Pay for a priced drop and receive the full payload. 99% goes to the creator, 1% platform. Request: POST https://api.infiniteocean.io/purchase/ Content-Type: application/json X-Ocean-Key: { "route": "myapp/premium", "key": "article-1" } Notes: - Idempotent: purchasing the same drop twice returns the payload without charging again. - Owner bypass: if you are the drop creator, returns full payload without charging. - Receipt stored as entity at __purchases__/{buyerKey}:{dropId} for verification. 38. GET /ledger — Transaction history ────────────────────────────────────── View your io transaction history. Every transfer, charge, and grant is recorded in a hash-chained ledger (part of the trust chain). Request: GET https://api.infiniteocean.io/ledger?limit=50&offset=0 X-Ocean-Key: Transaction types: transfer — P2P send (POST /io/send) or function purchase (POST /purchase) topup — Stripe purchase charge — compute charge on a network node (99% to operator, 1% platform) Trust chain: Every ledger entry is a drop on the __ledger__/txns route, which enters the SHA-256 hash-chained root manifest. Every io transaction is tamper-evident and independently verifiable by any node. 39. Billing Delegation — _billing consent entity ────────────────────────────────────────────────── Separates "who wrote the function" from "who pays for compute." By default, compute for public functions is billed to the developer's app_key (from _config). When a client takes over a function route, they can opt into paying for compute by writing a _billing entity.
-- Opt into paying for a function's compute
save fn/my-shop @_billing {}

-- Revoke billing (stop paying)
delete fn/my-shop @_billing
Billing priority for compute charges: 1. _billing.billing_key (explicit consent — payer wrote the entity) 2. _config.app_key (public function — developer pays) 3. requestKey (caller pays — default) The server auto-injects billing_key (the writer's X-Ocean-Key) — cannot be forged. 40. GDPR Compliance — Erasure, Redaction, Retention ──────────────────────────────────────────────────── For apps built on InfiniteOcean that store data about their end-users. Requires write access to the target prefix (X-Ocean-Key header). POST /gdpr/erase — Right to Erasure: POST https://api.infiniteocean.io/gdpr/erase Headers: X-Ocean-Key: Body: { "prefix": "myapp/users", "filter": { "payload.user_id": "cust_123" } } POST /gdpr/redact — Field-Level Redaction: POST https://api.infiniteocean.io/gdpr/redact Headers: X-Ocean-Key: Body: { "prefix": "myapp/users", "fields": ["email", "name", "phone"], "filter": { "payload.user_id": "cust_123" } } POST /gdpr/retention — Set Retention Policy: POST https://api.infiniteocean.io/gdpr/retention Headers: X-Ocean-Key: Body: { "prefix": "myapp/logs", "max_age_days": 90 } Right of Access: Use GET /export/:prefix?latest=true&filter={"payload.user_id":"cust_123"} 41. primitives — Cross-route primitive query ─────────────────────────────────────────────
-- Query by date range
primitives dates { "$gte": "2026-03-01", "$lt": "2026-04-01" }

-- Query by location
primitives locations { "$near": { "lat": 55.68, "lon": 12.57, "radius_km": 5 } }

-- Query by identifier
primitives identifiers { "$has": { "type": "email", "value": "bob@example.com" } }

-- Combined query (AND logic)
primitives dates { "$gte": "2026-03-01" } amounts { "$gte": 1000, "$unit": "USD" }
All filter fields are optional. At least one is required. When multiple filters are provided, results are intersected (AND logic). Date filters: "$gte" — start date >= value (ISO 8601 or YYYY-MM-DD) "$gt" — start date > value "$lte" — start date <= value "$lt" — start date < value Location filters: "$near" — { "lat": number, "lon": number, "radius_km": number } Identifier filters: "$has" — { "type": "email", "value": "a@b.com" } exact type:value match "$has" — "a@b.com" match value across all types "$type" — "drop" all identifiers of this type "$any" — [{ "type": "email", "value": "a@b.com" }, ...] OR match Amount filters: "$gte", "$gt", "$lte", "$lt" — range on value "$unit" — filter by unit (e.g. "USD", "kg"). Default: all units. Access control: results are filtered per-route — you only see entities on routes you have read access to. UNIVERSAL FIELDS — Standardized Payload Fields ──────────────────────────────────────────────── Optional, well-known payload fields that enable cross-route queries. Convention, not enforcement — writes stay schema-free. When present, these fields are automatically indexed. All universal fields use `_` prefix. All are arrays. _texts — Words, messages, thoughts ["Sprint planning notes", "Q3 slides reminder"] _dates — Points and ranges in time [{ "start": "2026-03-15T09:00:00Z", "end": "2026-03-15T10:30:00Z", "tz": "Europe/Copenhagen", "label": "meeting" }] Fields: start (required), end, tz, label _identifiers — References to anything [{ "type": "email", "value": "alice@example.com", "name": "Alice", "role": "organizer" }, { "type": "github", "value": "octocat" }, { "type": "drop", "value": "work/meetings\0q3-review", "label": "related" }] Fields: type (required), value (required), name, role, label Common types: email, tel, key, drop, github, domain, iban, eik, url _blobs — Files and attachments [{ "url": "/blob/abc123", "mime": "image/png", "name": "screenshot.png", "size": 245760 }] Fields: url (required), mime (required), name, size _locations — Coordinates [{ "lat": 55.6761, "lon": 12.5683, "name": "Copenhagen" }] Fields: lat (required), lon (required), name, address, city, country _amounts — Quantities and money [{ "value": 1500, "unit": "USD", "label": "total" }, { "value": 2.3, "unit": "kg", "label": "weight" }] Fields: value (required), unit, label Full example:
-- Complex nested payloads use JSON syntax (always supported)
save work/meetings @q3-review {
  "title": "Q3 Budget Review",
  "_texts": ["Quarterly budget review with marketing"],
  "_dates": [{"start": "2026-03-15T09:00:00Z", "tz": "Europe/Copenhagen"}],
  "_identifiers": [
    {"type": "email", "value": "alice@example.com", "role": "organizer"},
    {"type": "email", "value": "bob@example.com", "role": "attendee"}
  ],
  "_locations": [{"lat": 55.67, "lon": 12.56, "name": "HQ"}],
  "_amounts": [{"value": 250000, "unit": "USD", "label": "budget"}],
  "_blobs": [{"url": "/blob/q3", "mime": "application/pdf"}]
}
This drop is now queryable via primitives by date, location, identifier, amount, or any combination — across all routes. 42. mail — Email as Drops ═════════════════════════ Full email infrastructure built into InfiniteOcean. Claim a subdomain mailbox (username@username.infiniteocean.io), add custom domains, send and receive email — all stored as drops with universal fields.
-- Claim a subdomain mailbox (100 io)
mail claim "alice"

-- Send an email (1 io)
mail to "bob@example.com" subject "Hello" body "Hello from the ocean"

-- Add a custom domain (500 io)
mail domain add "mycoolsite.com"
Mail storage routes: mail/{username}/sent — outbound emails (one drop per email) mail/{username}/inbound — received emails (one drop per email) mail/domains — domain ownership records Every email drop includes universal fields (_texts, _dates, _identifiers) so emails are queryable via primitives and cross-route search. Username rules: 3-30 chars, a-z 0-9 and hyphens. Must not start/end with hyphen. Sending rules: from: required — must be an address on a verified domain you own to: required — recipient email (string or array) subject: optional — defaults to "(no subject)" html: optional — HTML body text: optional — plain text body (fallback) reply_to: optional — reply-to address cc/bcc: optional — CC/BCC recipients Custom domain DNS: POST /mail/domain returns DNS records to add at your registrar. After adding records, call POST /mail/domain/verify to confirm. GET /mail/my-domains — List your mail domains (HTTP-only). Query your inbox: find mail/alice/inbound latest sort created_at desc limit 20 Search your mail: search mail/alice/inbound "invoice" top 10 Find emails across all routes by sender: primitives identifiers { "$has": { "type": "email", "value": "bob@example.com" } } Inbound email (POST /mail/inbound, POST /mail/events): These are internal webhook endpoints called by Resend — not user-facing. Inbound emails arrive as drops in mail/{username}/inbound. 43. push — Web Push Notifications ══════════════════════════════════ Native Web Push (RFC 8292 VAPID + RFC 8291 encryption) built into InfiniteOcean. Free — no io charge for push notifications.
-- Send a push notification to users
push "New message" to ("ocean-key-1", "ocean-key-2") body "You have a new message"

-- Subscribe to push on route drops
push subscribe "myapp/orders"

-- List push subscriptions
push subscriptions
GET /push/vapid-key — Get VAPID public key (HTTP-only, needed by browser Push API): Returns: { "publicKey": "base64url-encoded-P256-public-key" } The client uses this key with PushManager.subscribe(): const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) }); POST /push/subscribe — Register a browser push subscription (HTTP-only): Body: { "endpoint": "https://...", "keys": { "p256dh": "...", "auth": "..." } } Returns: { "subscription_id": "uuid" } DELETE /push/subscribe/:id — Remove a push subscription (HTTP-only) Route subscriptions: Subscribe to push notifications whenever a drop is written to a matching route. Works like webhooks but delivers to browser push instead of HTTP POST. POST /push/route-subscribe — Subscribe to route drops (HTTP-only): Body: { "prefix": "myapp/orders" } DELETE /push/route-subscribe/:id — Remove route subscription (HTTP-only) GET /push/route-subscriptions — List route subscriptions (HTTP-only) Expired subscriptions (HTTP 410) are automatically cleaned up. 44. values — Custom currencies & tokens ══════════════════════════════════════════ Create your own currency, mint tokens, transfer between keys with 0.1% platform fee.
-- Create a currency
value create "gold" name "Gold Coins" symbol "GLD" max_supply 1000000 decimals 2
-- Mint tokens (creator only)
value mint "gold" 1000
value mint "gold" 500 to "recipientKey"
-- Transfer (0.1% fee paid by sender on top)
value transfer "gold" 100 to "recipientKey"
-- Burn tokens from your balance
value burn "gold" 50
-- Read operations
value balance "gold"
value balance "gold" holder "otherKey"
value supply "gold"
value currencies
value holders "gold"
value ledger "gold"
HTTP endpoints: POST /values/create — Create currency (id, name, symbol, scope, max_supply, decimals) POST /values/mint — Mint tokens (currency, amount, to) POST /values/transfer — Transfer tokens (currency, amount, to) — 0.1% fee POST /values/burn — Burn tokens (currency, amount) GET /values/balance?currency=X&holder=Y — Check balance GET /values/currencies?creator=X — List currencies GET /values/currency/:id — Currency details GET /values/holders?currency=X — List holders GET /values/supply?currency=X — Total and max supply GET /values/ledger/:id?limit=N — Transaction history Scope: "public" (default, anyone can receive) or "private" (only existing holders or creator). Transfer fee: 0.1% platform fee. Sender pays amount + fee, recipient gets exact amount. 45. list — List entity keys ────────────────────────────
-- List all entity keys on a route
list users
Returns entity keys for the given route. 45. context — Get project context ───────────────────────────────────
-- Get full project context (architecture, schemas, memory)
context
Returns agent context including architecture decisions, code patterns, bug history, schemas, and lessons from all previous sessions. 46. event / events / rsvp — Calendar ──────────────────────────────────────
-- Create a calendar event
event work/meetings @standup title "Daily Standup" start "2026-03-15T09:00:00Z"

-- List events on a route
events work/meetings

-- RSVP to an event
rsvp work/meetings @standup accepted

-- Get calendar feed URL
calendar feed "work/meetings"
ICS feeds, invite emails, and RSVP built into InfiniteOcean. Any entity with start+title fields is treated as an event. Feed tokens at _calendar/tokens. 47. subkey — Sub-key management ────────────────────────────────
-- Create a sub-key with restricted permissions
subkey create label: "read-only" permissions: ("read", "search")

-- List sub-keys
subkey list

-- Revoke a sub-key
subkey "sub-key-id" revoke
48. GET /listen/:route — Continuous binary stream ────────────────────────────────────────────────── Stream raw binary data from blob drops on a route as a continuous HTTP stream. Designed for audio, video, or any binary pipeline — works as a direct `