Documentation

Base URL: https://api.infiniteocean.io — secure by default

Drops-first

Everything InfiniteOcean can do, with examples you can copy and run. The Drops language is the simplest way to interact with all features. Standard web requests still work too.

Full Drops language reference →

Quick start

All examples use the Drops language. Full reference →

-- 1. Save data
save my/data @user-1 name: "Alice" email: "[email protected]"

-- 2. Get it back
get my/data @user-1

-- 3. Find by field
find my/data where name = "Alice"

-- 4. Full-text search
search my/data "alice" top 5

-- 5. Or just use plain words for complex queries
find my/data where name = "Alice" then pick name, email
// JavaScript — send Drops via POST /exec
const res = await fetch("https://api.infiniteocean.io/exec", {
  method: "POST",
  headers: {
    "Content-Type": "text/plain",
    "X-Ocean-Key": "YOUR_KEY"
  },
  body: 'save my/data @user-1 name: "Alice"'
});
const result = await res.json();

// SSE streaming still uses HTTP (live events)
const events = new EventSource("https://api.infiniteocean.io/stream/my/*?last=10");
events.onmessage = (e) => console.log(JSON.parse(e.data));
# Save data
curl -X POST https://api.infiniteocean.io/exec \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: YOUR_KEY" \
  -d '{"query":"save my/data @user-1 name: \"Alice\" email: \"[email protected]\""}'

# Get it back
curl -X POST https://api.infiniteocean.io/exec \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: YOUR_KEY" \
  -d '{"query":"get my/data @user-1"}'

# Find by field
curl -X POST https://api.infiniteocean.io/exec \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: YOUR_KEY" \
  -d '{"query":"find my/data where name = \"Alice\" then pick name, email"}'

Saving & Live Updates

Save information. Watch for changes in real time.

-- Save a drop (event)
save myapp/events/signup user: "alice" plan: "pro"

-- Save a keyed entity (versioned)
save myapp/events/signup @latest user: "alice" plan: "pro"

-- Save with TTL (auto-remove after 3600 seconds)
save myapp/sessions @sess-1 user: "alice" keep 3600

Live updates — replay history and subscribe to new changes as they happen:

URLBehavior
/stream/myapp/*?last=5Last 5 drops, then live
/stream/myapp/*?from=0Full history replay, then live
/stream/myapp/events/signupExact route, live only
// SSE stream format
data: {"drop_id":"...","route":"...","payload":"...","created_at":"..."}

event: caught-up
data: {"timestamp":"...","replayed":2}

data: {"drop_id":"...","route":"...","payload":"...","created_at":"..."}  ← live


Finding & Filtering

Find, sort, and summarize your data using simple filters.

-- Filter by payload field
find myapp/events where type = "signup" sort created_at desc limit 10

-- Numeric comparisons
find myapp/orders where amount > 100 sort amount desc

-- Text contains
find myapp/logs where message contains "error" limit 20

-- Count results with then
find myapp/events where type = "signup" then count

-- Pick specific fields
find myapp/users then pick name

Aggregation and joins are available for advanced use cases like grouping, summing, averaging, counting, and combining data from multiple folders. Or use SQL:

-- Group and aggregate via SQL
find myapp/orders group category sum amount count

-- Join via SQL
find orders join users on user_id = key then pick *, name

Built-in functions: $sum, $avg, $min, $max, $count, $first, $last, $distinct


Smart Fields

Six optional, well-known fields that let you search across all your data at once. Add them to any record and instantly find things by date, location, identity, or amount — no matter where they are stored. Full reference →

-- Write a drop with universal fields
save work/meetings @standup-42 
  title: "Weekly Standup"
  _dates: ({ start: "2026-03-15T09:00:00Z" })
  _identifiers: (
    { type: "email", value: "[email protected]", role: "organizer" },
    { type: "email", value: "[email protected]", role: "attendee" }
  )
  _locations: ({ lat: 55.68, lon: 12.57, name: "Copenhagen" })

-- Cross-route query: find everything involving bob in March
primitives dates { $gte: "2026-03-01", $lt: "2026-04-01" }
  identifiers { $has: { type: "email", value: "[email protected]" } }

-- With location filter
primitives dates { $gte: "2026-03-01" }
  locations { $near: { lat: 55.68, lon: 12.57, radius_km: 10 } }

The six fields — all start with _ and hold lists of values:

FieldWhat it holdsQueryable by
_textsStrings. Automatically included in smart search.search
_datesObjects with start, optional end, tz, label.Date range
_identifiersObjects with type + value, optional name, role.Exact, type, value, OR
_locationsObjects with lat + lon, optional name.Geo proximity
_amountsObjects with value, optional unit, label.Range per unit
_blobsObjects with url + mime, optional name, size.Filter on payload

All filters are combined together. Results include records from any folder you have access to. You can also combine smart field filters with regular query filters.


Versioned Records

Add a @key to any write to create a versioned record. Writing again with the same folder + key creates a new version. Delete by writing null. Full history is always preserved.

-- CREATE
save users @user-123 name: "Alice" email: "[email protected]"

-- READ
get users @user-123

-- UPDATE (write again with same key)
save users @user-123 name: "Alice Updated" email: "[email protected]"

-- DELETE (tombstone)
remove users @user-123

-- LIST all current entities (deduped, deleted excluded)
list users

-- PATCH (partial update — merge fields, unmentioned preserved)
update products @sku-42 price: 29.99 on_sale: true

-- Remove a field by patching null
update products @sku-42 on_sale: null

Version history — every write creates a new version. Use GET /entity/:route?key=:key&history=true to list all versions, or &at=<timestamp> to fetch a specific historical version.

Auto-parsing: Stored data is automatically structured on retrieval.


Summarizing

Ask questions about your data using familiar query syntax. Field mapping is automatic: name in a query maps to the right place internally. Special fields key, created_at, drop_id, route, source pass through directly.

-- Save, get, update, remove
save users @alice name: "Alice" email: "[email protected]"
users @alice
update users @alice name: "Alice V2"
remove users @alice

-- Find with filters
find users where age > 21 and status = "active"
find users where name starts with "A"
find users where role in ("admin", "editor")
find users where email exists

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

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

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

-- Define schemas (optional)
define "users" fields { name: "text required", email: "text", age: "number" }
status users
-- Remove a schema definition

-- Atomic batch (all succeed or all fail)
bulk (save ledger @tx1 amount: 100, save ledger @tx2 amount: -100)

Privacy & Permissions

Quick Start (open)

Everything starts open so you can try things quickly. Lock it down when you're ready.

Folder Permissions (recommended)

Secure any folder with a _access record. Four modes: shared (restrict who can read and write), protected (anyone can read, only approved people can write), writeonly (anyone can write, only approved people can read), or moderated (anyone can read and write, only moderators can delete).

Internal

Folders starting with _ (single underscore): _memory, _cron, _mcp, etc. Anyone can read, but writing requires your key. First writer owns the record — others cannot overwrite it.

Private

Folders named after your key are automatically private. Only you can read and write. You can share access to specific apps.

Grants

Give apps access to specific parts of your private data. You share, you unshare. Your data stays under your control.

Shared

Folders starting with shared/. A trust record at trust/{name} controls who can access shared/{name}/*.

Trust

Folders starting with trust/ are system-managed. First write establishes ownership. Direct reads are blocked.

Folder permissions — use share and unshare to control access to any folder. Four modes: shared (reads + writes restricted), protected (anyone reads, restricted writes), writeonly (anyone writes, restricted reads), and moderated (anyone reads and writes, only moderators delete). Permissions inherit downward — locking agency/shop also protects agency/shop/products.

-- Lock down a route (shared mode — both reads and writes restricted)
share agency/shop with @YOUR_KEY mode shared

-- Protected mode — public reads, restricted writes
share agency/shop with @YOUR_KEY mode protected

-- Writeonly mode — anyone can write, only granted keys can read
-- Use case: signup forms, feedback, anonymous submissions
share feedback/inbox with @YOUR_KEY mode writeonly

-- Moderated mode — anyone can post, only moderators can delete
-- Use case: community chat, forums, comments, reviews
share community/general with @MODERATOR_KEY mode moderated

-- Add another key to a route's grant list
share agency/shop with @CLIENT_KEY

-- Remove access
revoke agency/shop from CLIENT_KEY

-- Remove ACL entirely (only the owner can do this)
revoke agency/shop

Permissions: stream, search, query, entity, sql, export (read operations), drop (write). Each controls a specific capability.

Private folders: Folders starting with your public key are automatically private. Grant access to apps without revealing your key.


Ownership

Claim

Adopt any key (device, agent, service) from your personal key. Auto-grants you full access to the claimed key's private data.

Manage

List all your claimed keys, check ownership, update labels. One account, many devices.

Transfer

Hand a device to another owner in one call. Old grants revoked, new grants written atomically.

Release

Unclaim a key to revoke your access. The device keeps full access to its own data.

# Claim a device key
curl -X POST https://api.infiniteocean.io/key/claim \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $KEY" \
  -d '{"key":"DEVICE_KEY","label":"Living room camera","type":"device"}'

# List all keys you own
curl -s https://api.infiniteocean.io/key/claimed \
  -H "X-Ocean-Key: $KEY"

# Check who owns a key
curl -s https://api.infiniteocean.io/key/owner/DEVICE_KEY

# Read device data using your key (auto-granted)
curl -s https://api.infiniteocean.io/entity/DEVICE_KEY/sensors/temperature?key=latest \
  -H "X-Ocean-Key: $KEY"  # → 200

# Transfer ownership to another key
curl -X POST https://api.infiniteocean.io/key/transfer \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $KEY" \
  -d '{"key":"DEVICE_KEY","to":"NEW_OWNER_KEY"}'

# Release ownership
curl -X DELETE https://api.infiniteocean.io/key/claim/DEVICE_KEY \
  -H "X-Ocean-Key: $KEY"

How it works: Claiming automatically grants you access to the device's data. The device keeps its own access unchanged. Transferring moves ownership to someone else in one step. Releasing removes your access.


Running Things

All operations are free — reads, writes, search, SQL, functions. No per-request charges. Every node on the network contributes automatically, so workloads are distributed. io credits are only used for priced content (records with a price field).

Everything is free

Saving, reading, searching, running code, querying, exporting — all free. No io cost for any operation.

Priced content

Set a price on any record to sell access. Buyers pay in io, and 99% goes directly to you. Great for APIs, datasets, templates, or premium content.

GET /whoami Check your account
# Anonymous
curl https://api.infiniteocean.io/whoami
→ { "tier": "anonymous", "ip": "..." }

# With key
curl https://api.infiniteocean.io/whoami -H "X-Ocean-Key: your-key"
→ { "tier": "balance", "key": "your-key", "balance": 947 }

io balances are used for purchasing priced content (records with a price field). All operations — search, SQL, functions — are free regardless of balance. Send io to another user, or receive io from others.

POST /io/send Send io to another user

Transfer io by @handle, email, or public_id.

FieldDescription
toRecipient: @handle, email, or public_id (required)
amountWhole number of io to send (required)
curl -X POST https://api.infiniteocean.io/io/send \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: your-key" \
  -d '{"to":"@bob","amount":100}'

→ { "ok": true, "sent": 100, "to": { "handle": "@bob" }, "remaining": 850 }

Monetize with paid content

Add a price field to any record to require payment before the full content is visible. Non-purchasers see a preview but the content is hidden. 99% of each purchase goes to you, 1% to the platform.

-- Write a drop with a 5 io price tag
save myapp/premium @article-1 "full content here" price 5

-- Non-purchaser reads it → payload stripped
get myapp/premium @article-1
-- { drop_id: "...", price: 5, _gated: true }

-- Purchase via HTTP: POST /purchase/<drop_id>

Custom function pricing

Set a price on your function's _config record. Callers are charged upfront — 99% goes to you, 1% to the platform.

-- Set a 10 io price on your function
save myapp/fn/premium @_config execute: "public" price: 10

-- When someone runs: run myapp/fn/premium { input }
-- 10 io transferred from caller to you (99/1 split)

Transaction ledger

Every io movement — transfers, charges, grants — is permanently recorded. View your transaction history anytime.

GET /ledger View your io transaction history
ParamDescription
limitMax entries (default 100, max 1000)
offsetSkip first N entries (default 0)
curl https://api.infiniteocean.io/ledger?limit=10 \
  -H "X-Ocean-Key: your-key"

→ { "entries": [
    { "type": "transfer", "amount": 100, "from": "you", "to": "bob",
      "fee": 1, "net": 99, "from_bal": 900, "to_bal": 1099, "txn_id": "..." },
    { "type": "drain", "amount": 1, "from": "you", "from_bal": 899, "txn_id": "..." }
  ], "count": 2 }

Types: transfer (P2P send or purchase), topup (Stripe purchase). You only see your own transactions.


Files & Media

Upload any file. Stream video and audio. File information is stored alongside your other data, so files are searchable and queryable like everything else.

POST /blob Upload binary

Send the raw file as the request body. Folder and content type go in headers.

HeaderDescription
Content-TypeMIME type of the file (image/png, video/mp4, audio/mpeg, etc.)
X-Blob-RouteFolder for the file's metadata record.
X-Ocean-KeyOptional. Your key for private folders.
# Upload a video
curl -X POST https://api.infiniteocean.io/blob \
  -H "Content-Type: video/mp4" \
  -H "X-Blob-Route: myapp/videos" \
  --data-binary @movie.mp4
// Response
{
  "drop_id": "a1b2c3d4-...",
  "route": "myapp/videos",
  "source": "base64-public-key",
  "blob": {
    "key": "blobs/a1b2c3d4-...",
    "size": 15728640,
    "type": "video/mp4"
  },
  "created_at": "2026-02-07T12:00:00.000Z"
}

Max size per single request: 100 MB. For larger files, use chunked uploads.

POST /upload/start Chunked upload (any size)

Upload files of any size in 10 MB chunks. Resumable — if a connection drops, check progress and continue from where you left off.

FieldDescription
routeFolder for the file's metadata record.
typeMIME type of the file.
sizeTotal file size in bytes.
nameFile name.
# 1. Start the upload
curl -X POST https://api.infiniteocean.io/upload/start \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: your-key" \
  -d '{"route":"myapp/videos","type":"video/mp4","size":524288000,"name":"big-video.mp4"}'

→ { "upload_id": "uuid", "part_size": 10485760, "total_parts": 50 }

# 2. Upload each part (10 MB chunks)
curl -X PUT https://api.infiniteocean.io/upload/uuid/part/1 \
  -H "Content-Type: application/octet-stream" \
  --data-binary @chunk-1.bin

→ { "part": 1, "etag": "...", "remaining": 49 }

# 3. Check progress (for resume)
curl https://api.infiniteocean.io/upload/uuid

→ { "upload_id": "uuid", "total_parts": 50, "completed_parts": [1, 2, 3] }

# 4. Complete when all parts are uploaded
curl -X POST https://api.infiniteocean.io/upload/uuid/complete

→ { "drop_id": "uuid", "route": "myapp/videos", "blob": { "size": 524288000, ... } }
GET /blob/:id Stream binary

Returns the file with its original type. Supports seeking in video and audio players.

# Stream a video (full)
curl https://api.infiniteocean.io/blob/a1b2c3d4-... -o video.mp4

# Range request (bytes 0-1023)
curl -H "Range: bytes=0-1023" https://api.infiniteocean.io/blob/a1b2c3d4-...
→ HTTP 206 Partial Content
// Play in a browser — just use the URL
<video src="https://api.infiniteocean.io/blob/a1b2c3d4-..." controls></video>
<audio src="https://api.infiniteocean.io/blob/a1b2c3d4-..." controls></audio>
<img src="https://api.infiniteocean.io/blob/a1b2c3d4-...">

Blob responses include Cache-Control: public, max-age=31536000, immutable and Accept-Ranges: bytes. (Blobs are content-addressed by UUID — they never change.)

// JavaScript — upload + embed
const file = document.querySelector('input[type="file"]').files[0];

const res = await fetch("https://api.infiniteocean.io/blob", {
  method: "POST",
  headers: {
    "Content-Type": file.type,
    "X-Blob-Route": "myapp/uploads",
  },
  body: file,
});
const { drop_id } = await res.json();

// Now playable forever
video.src = `https://api.infiniteocean.io/blob/${drop_id}`;

See it in action — this video is served from InfiniteOcean:

Find your files — each upload creates a record with file information, so you can search, query, or use SQL to find files by folder, time, or any details you included.

Client libraries: ocean-rtc.js enables peer-to-peer video and audio calls through InfiniteOcean. One-to-one calls via OceanRTC, group rooms via OceanRoom. No media server needed.

GET /listen/:route Continuous binary stream

Stream raw binary data from blob drops as a continuous HTTP stream. Works directly as an <audio> or <video> source in all browsers — no JavaScript needed.

ParamDescription
?type=Override content type (e.g. audio/mpeg). Auto-detected by default from the blob's .type file or codec payload field.
<!-- Use it directly as an audio or video source -->
<audio src="https://api.infiniteocean.io/listen/myapp/radio" controls></audio>
<video src="https://api.infiniteocean.io/listen/myapp/stream" controls></video>

<!-- Override content type -->
<audio src="https://api.infiniteocean.io/listen/myapp/radio?type=audio/mpeg" controls></audio>

<!-- Private route -->
<audio src="https://api.infiniteocean.io/listen/myapp/radio?key=YOUR_KEY" controls></audio>

On connect, the most recent segment plays immediately. New segments are piped as they arrive via chunked transfer encoding. Content type is auto-detected from the blob's .type file, the codec field in the payload, or falls back to application/octet-stream. Each blob drop must have a blob_id field in its payload.


Sign-In

Sign people in with email. No passwords needed — they click a link and they're in. For social login (Google, GitHub, Apple), verify the email through your preferred provider, then resolve it to an Ocean Key. No passwords, no sessions, no cookies.

POST /auth/login Send magic link

Send a sign-in email. If the email is new, an Ocean key is created and stored. If returning, the same key is used. The key is never revealed in this response.

FieldDescription
emailRequired. Normalized to lowercase.
callback_urlOptional. Magic link points here instead of InfiniteOcean. Your backend verifies the token.
app_nameOptional. Shown in the email heading and "Authenticated by InfiniteOcean" footer.
subjectOptional. Custom email subject. Defaults to "Sign in to {app_name}" or "Sign in to InfiniteOcean".
# Direct — link goes to API, returns JSON
curl -X POST https://api.infiniteocean.io/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]"}'

# As auth provider for your app — link goes to your domain
curl -X POST https://api.infiniteocean.io/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","callback_url":"https://yourapp.com/auth/callback","app_name":"YourApp"}'

→ { "sent": true }

Rate limit: 5 requests per email per hour. With callback_url, the magic link points to callback_url?token=... — your backend calls GET /auth/verify?token=... to get the key.

GET /auth/verify?token=... Verify token, get key

Verify a magic link token and get the Ocean key. Called by your backend after the user clicks the link. Token is single-use and expires after 15 minutes.

curl https://api.infiniteocean.io/auth/verify?token=abc123...

→ { "key": "base64url-ocean-key" }
GET /auth/me Who am I?

Look up the email and account details for a key obtained via magic link.

curl https://api.infiniteocean.io/auth/me \
  -H "X-Ocean-Key: your-key"

→ { "email": "[email protected]", "key": "...", "tier": "authenticated" }
POST /auth/resolve Resolve email to Ocean Key (social login)

Probe-only: tells you whether an account exists for the given email. Does not return a key, does not create an account, does not send mail. Used by OAuth flows to decide between "first-time signup" and "returning user" branches before redirecting through the magic-link flow.

Behavior changed 2026-04-29: previously returned ocean_key; now returns only existence + public_id. Account creation happens at /auth/verify (magic-link click), not here. No X-Ocean-Key header required.

FieldDescription
emailRequired. The email address to probe. Normalized to lowercase.
# If account exists
→ { "is_new": false, "public_id": "a1b2c3d4e5f6" }

# If account does not exist
→ { "is_new": true }
# Resolve a verified email → Ocean Key
curl -X POST https://api.infiniteocean.io/auth/resolve \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: developer-key" \
  -d '{"email":"[email protected]"}'

→ 201: { "ocean_key": "...", "public_id": "a1b2c3d4e5f6", "is_new": true }
→ 200: { "ocean_key": "...", "public_id": "a1b2c3d4e5f6", "is_new": false }

Requires X-Ocean-Key header (developer key). Rate limit: 20 resolves per key per hour. Returns 201 for new users, 200 for existing.

For full OAuth tutorials with Google and GitHub, see Social Login Tutorial.

// Magic link flow
await fetch("https://api.infiniteocean.io/auth/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    email: "[email protected]",
    callback_url: "https://yourapp.com/auth/callback",
    app_name: "YourApp"
  })
});

// Verify token → get key
const token = new URLSearchParams(location.search).get("token");
const { key } = await (await fetch(`https://api.infiniteocean.io/auth/verify?token=${token}`)).json();

// OAuth flow — resolve a verified email directly
const { ocean_key } = await (await fetch("https://api.infiniteocean.io/auth/resolve", {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-Ocean-Key": DEVELOPER_KEY },
  body: JSON.stringify({ email: verifiedEmail })
})).json();

Agents — bootstrap without email

For CLIs and AI agents that don't have email, the platform supports a headless bootstrap path: POST /drop without an X-Ocean-Key header generates a fresh Ed25519 keypair server-side and returns both halves once in the response. Save both halves at that moment — they will not be returned again.

# Bootstrap a keypair without email
curl -X POST https://api.infiniteocean.io/drop \
  -H "Content-Type: application/json" \
  -d '{"route":"hello","payload":"first drop"}'

→ { "drop_id": "...", "route": "hello", "source": "<public>",
    "keypair": { "public": "<public>", "private": "<private>" } }

Crypto-conscious callers (hardware-backed keys, etc.) can opt out of server-side keypair generation with ?generate_keypair=false — anonymous calls then return 401 KEY_REQUIRED and the caller must generate the keypair locally. The server briefly holds the private key in memory during response generation; it is never persisted. The TLS connection is the only wire-side protection. If you can't tolerate that window, generate locally and pass X-Ocean-Key.

Automatic Triggers

Register, fire, verify

Register a URL to be notified whenever new data is saved to a folder. When matching data is written, the full record is sent to your URL automatically.

Optional signature verification: provide a secret when registering to verify that notifications genuinely come from InfiniteOcean.

Each trigger has an owner (the key that registered it). Only the owner can list or delete it.

Delivery details

Timeout: 10 seconds per attempt. Retries: none (fire-and-forget). URL: must be HTTPS. Delivery is in write order per folder. The full record is sent in the request body.

Toggle triggers on/off with "active": false without deleting.

-- React to changes
when orders changes call "https://my-server.com/hook"

-- With webhook secret
when orders changes call "https://my-server.com/hook" secret "s3cret"

-- List webhooks
webhooks

-- Delete a webhook
webhook delete "hook-id-here"

Counters

Safe counters

Use add and subtract for counters that are safe even with many simultaneous updates. No conflicts, no matter how many requests happen at once.

Counter updates are sub-millisecond. Counter values are automatically included in reads, queries, and SQL results.

-- Add to a counter
add 1 to pages @home views

-- Add a larger amount
add 5 to pages @home views

-- Subtract
subtract 1 from pages @home stock

-- Read — counter value is merged in
get pages @home
-- { title: "Home", views: 5 }

-- Counter value is merged in
add 1 to pages @home views

Fast Lookups

Instant access

Record lookups by key are automatic and instant, no matter how much data you have.

For specific fields you query often, create an index to speed things up even more.

# Entity index is automatic — instant reads
GET /entity/users?key=alice    ← instant lookup, any dataset size

# Create a field index
POST /exec {"query":"status users"}
# Indexes are created automatically on frequently-queried fields
→ { "command": "CREATE_INDEX", "table": "users", "field": "email" }

# Now queries on email are index-accelerated
POST /exec {"query":"find users where email = \"[email protected]\""}
← reads only matching entities, skips full scan

# Show and drop indexes
POST /exec {"query":"status users"}
→ { "columns": ["table","field"], "rows": [["users","email"]] }

Publishing Pages

Data as web pages

Save HTML and view it in a browser. Named records give you permanent URLs — update the record, the page updates. Instant publishing with full version history.

Served from view.infiniteocean.io — isolated for security. File type is detected from the key extension (.css, .js, .png, etc.) or defaults to HTML.

Privacy works too — private and shared folders require ?_key= to view. Priced records return 402 until purchased.

Security headers

All view responses include these headers automatically:

  • Cache-Control: public, no-cache — always revalidates, page 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 is not set by default — the platform is multi-tenant and a global CSP breaks sites that use external resources (Google Fonts, CDNs, etc.).

To add CSP to your site, save a _config entity on your view route with a csp field:

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 will appear on all pages under that route. Remove it by tombstoning: save mysite @_config null

Relative paths & directory URLs

Use href="style.css" in your HTML — it just works. Directory-style URLs auto-redirect to add a trailing slash (/mysite/mysite/), so relative asset paths resolve correctly.

Keys can contain / (e.g. pages/about). The system automatically detects this and finds the right record.

Deletion protection

If a record is accidentally deleted, the page still serves the previous live version. Your pages stay up even if data is removed — only direct lookups show "deleted".

Custom domains

Map your own domain to a folder. Add a CNAME pointing to view.infiniteocean.io (or an A record for apex domains), register it, and your domain serves your pages with automatic HTTPS.

DNS setup: Add a CNAME record, wait for propagation (usually minutes), then visit your domain — HTTPS is set up automatically on first visit. For apex domains, use an A record pointing to InfiniteOcean's IP.

# Write an HTML page
POST /drop
{"route":"mysite","key":"index","payload":"<!DOCTYPE html><html>..."}

# View it in a browser
https://view.infiniteocean.io/mysite/       ← serves the HTML
https://view.infiniteocean.io/mysite/index   ← same thing

# Add CSS and JS as separate drops
POST /drop
{"route":"mysite","key":"style.css","payload":"body { color: #fff }"}

https://view.infiniteocean.io/mysite/style.css  ← text/css

# Keys with "/" — works in View URLs
POST /drop
{"route":"mysite","key":"pages/about","payload":"<html>..."}
https://view.infiniteocean.io/mysite/pages/about  ← found!

# Custom domain — 3 steps
POST /domains
{"domain":"mycoolsite.com","route":"mysite"}
# 1. Add CNAME: mycoolsite.com → view.infiniteocean.io
# 2. Wait for DNS propagation (usually minutes)
# 3. Visit https://mycoolsite.com/ — TLS auto-provisioned

Downloading Data

Export in any format

Export all records under a folder as JSON, CSV, SQL, or MongoDB. Default is streaming NDJSON (one record per line). Pick a format and get a file ready for your target system.

Supports latest=true for deduplication, filter= for filtering, from=/to= for time ranges, and select= for choosing specific fields.

Formats:

  • JSON (default) — streaming NDJSON, one record per line
  • CSV — header row + data, nested fields flattened with dot notation
  • SQL — CREATE TABLE + INSERT INTO (PostgreSQL or MySQL)
  • MongoDB — db.collection.insertMany() script

Types are inferred automatically: TEXT, BIGINT, BOOLEAN, DOUBLE PRECISION, JSONB for Postgres; VARCHAR, BIGINT, TINYINT, DOUBLE, JSON for MySQL.

# HTTP export — pick a format
GET /export/users?latest=true                    → NDJSON (default)
GET /export/users?latest=true&format=csv          → CSV file
GET /export/users?latest=true&format=sql          → PostgreSQL
GET /export/users?latest=true&format=sql&dialect=mysql → MySQL
GET /export/users?latest=true&format=mongo        → MongoDB script

-- Drops syntax
export users                         → JSON (default)
export users as csv                  → CSV
export users as sql                  → PostgreSQL
export users as sql dialect mysql    → MySQL
export users as mongo                → MongoDB
-- 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);

Translate commands

See how any Drops command maps to SQL, curl, or REST. Your logic is never locked in — translate it to standard tools at any time.

Targets: sql (PostgreSQL), mysql, curl, rest

Commands: find, save, remove, get, search, count

-- 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 -H ... -d '...'

-- MySQL dialect
translate "find users where .role = 'admin'" to mysql
→ SELECT * FROM `users` WHERE `role` = 'admin';

Pipes

Use list or find with pipes to transform, filter, and act on data:

-- Drops: list entities and pipe results
list users then count
list users then pick name
list users then first
list users then sort name
list users then limit 10

-- Pipe actions: send email, save backup, remove
find customers then sort created_at desc then first then send email to email subject "Welcome!" body "Thanks for joining."
find users then first 10 then save backups/users @snapshot
find old/logs then remove

Running Code

Write code and run it

Write code and run it. Your code can read, write, search, and query your data. It gets an ocean client that can do everything you can do from the outside.

The Ocean client uses your key, so your functions have the same access as you do — read your folders, write your data, query your records.

Sync mode: 10 second timeout. Free.

Async mode: Add async: true for long-running functions. Poll with status command. Also free.

-- Store a function
save fn/hello @main "def main(ocean, request):
  return {'hello': request.get('name', 'world')}"

-- Run it
run fn/hello { name: "Ocean" }
-- { result: { hello: "Ocean" }, ms: 3 }

-- Run async (long-running)
run fn/process { data: "..." } async
-- { job_id: "abc123", status: "running" }

-- Poll status
status "abc123"

Compute

Function execution is free. No io cost regardless of runtime.

Ocean Client Methods

MethodDescription
ocean.read(route, key)Read an entity
ocean.write(route, payload, key)Write a drop
ocean.query(prefix, filter, sort, limit)Query drops
ocean.search(prefix, text, vector, top_k)Search
ocean.sql(query)Run SQL
ocean.run(route, body)Call another function
ocean.upload(route, data, filename)Upload a file
ocean.export_entities(route)Export all entities as a list

All methods raise OceanError on errors. Uncaught errors propagate as execution failures.

Function Isolation

Store a _config record alongside your function to enable code privacy and data isolation. The server automatically tracks function ownership so it cannot be forged.

Code privacy: Store functions in your private folder. Nobody can read the code. With execute:"public", anyone can run it.

Two-key model: When _config exists, ocean uses the developer's key (app state), and ocean.user uses the caller's key, limited to declared folders.

Public functions: When a function has execute:"public", anyone can call it — even without a key. Ideal for public services, notifications, and AI tools.

-- Store a private function
save fn/greet @main "def main(ocean, request):
  return {'hello': request.get('name')}"

-- Enable public execution with scoped user access
save fn/greet @_config execute: "public" scopes: ("myapp/users")

-- Anyone can now run it — code stays private
run fn/greet { name: "world" }

-- Set a price on your function (10 io per call)
save fn/premium @_config execute: "public" price: 10

AI Tool Connections

Connect your AI assistant to your functions

Connect your AI assistant to your InfiniteOcean functions. Claude Desktop, Cursor, VS Code, and other AI tools can call your functions directly. One config record defines the connection — tools are regular InfiniteOcean functions.

Stateless: Each request is independent. No sessions to manage.

Free: All AI tool connection operations are free — tool listing, initialization, and function execution.

Custom servers: Write a config record at _mcp/servers with tool definitions. Each tool maps to an InfiniteOcean function.

-- 1. Create a tool function
save my-tools/summarize @main "def main(ocean, request):
  url = request['url']
  # ... fetch and summarize ...
  return {'summary': text}"

-- 2. Create MCP server config
save _mcp/servers @my-server {
  name: "My MCP Server", version: "1.0.0",
  app_key: "YOUR_OCEAN_KEY",
  tools: {
    summarize: {
      description: "Summarize a URL",
      inputSchema: { type: "object",
        properties: { url: { type: "string" } } },
      function: "my-tools/summarize"
    }
  }
}

-- Now connectable at POST /mcp/my-server

Built-in AI connection: /mcp/ocean

Every account gets a built-in AI tool connection at POST /mcp/ocean with 4 core tools. No setup needed — just connect with your Ocean Key.

Primary tool: exec — executes Drops commands. This single tool gives access to every feature: write, read, query, search, SQL, functions, scheduled tasks, triggers, email, calendar, notifications, and more.

Other tools: get_context (full project context), remember (save dev notes), recall (search dev notes).

Auth: Pass your key as ?key= in the URL or via X-Ocean-Key header.

Cost: Everything is free.

Claude.ai: Add as a custom connector — paste the URL with your key, no OAuth needed.

# Claude Desktop / Cursor
{
  "mcpServers": {
    "ocean": {
      "url": "https://api.infiniteocean.io/mcp/ocean",
      "headers": {
        "X-Ocean-Key": "YOUR_KEY"
      }
    }
  }
}

# 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

# 4 tools:
# exec         — execute Drops (replaces 25 individual tools)
# get_context  — full project context + memory
# remember     — save dev notes to _memory
# recall       — search dev notes in _memory
#
# The exec tool accepts any Drops command:
#   write, read, delete, list, patch, query,
#   search, sql, run, status, cron, on, grant,
#   revoke, mail, event, events, rsvp, push,
#   validate, define, patterns, primitives, ...

Scheduled Tasks

Run things on a schedule

Run any function on a schedule. Standard schedule syntax: minute hour day month weekday. Scheduled tasks run the same way as on-demand function calls.

Your ocean key is stored with the task, so scheduled functions have the same data access as your regular calls.

All scheduled tasks are free — no cost to run.

-- Run daily at 9am
every day at 9am daily-report: run fn/report { to: "team" }

-- Run every 5 minutes
every 5 minutes health-check: run fn/ping

-- List scheduled jobs
schedules

-- Remove a schedule
schedule delete "daily-report"

Connections

Connect to any external service

Connections combine data, functions, triggers, and schedules — no extra infrastructure needed. Connect Stripe, Slack, GitHub, SendGrid, or anything with a web service.

Incoming triggers: External services send events to /webhook/:name. The raw event is captured as a record in _webhooks/{name}. An outbound trigger fires your handler function to process and route the event.

Polling triggers: Use scheduled tasks to call an external service on a timer. Your function tracks progress and saves new items.

Actions: Functions that call external services. Other functions can invoke them directly.

Workflows: Chain triggers, logic, and actions together using outbound triggers between functions.

Credentials: Store secret keys in your private folder ({KEY}/credentials/{name}). Your functions can read them securely.

Discovery: Connection registrations are records in _integrations, searchable via SQL.

# 1. Register an integration
POST /drop
{"route":"_integrations", "key":"my-stripe",
 "payload":"{\"type\":\"stripe\",\"active\":true}"}

# 2. External service sends events here:
#    POST /webhook/my-stripe
#    → drop written to _webhooks/my-stripe
#    → outbound webhooks fire

# 3. Deploy a handler function
POST /drop
{"route":"my-app/stripe-handler","key":"main",
 "payload":"def main(ocean, request):\n  event = json.loads(request['body'])\n  ocean.write('stripe/charges', event['id'], event)"}

# 4. Wire handler to webhook drops
POST /hooks
{"url":"https://api.infiniteocean.io/run/my-app/stripe-handler",
 "prefix":"_webhooks/my-stripe"}

# 5. Store credentials (private route)
POST /drop
{"route":"YOUR_KEY/credentials/stripe",
 "key":"webhook_secret", "payload":"whsec_..."}

# Discover integrations
POST /exec
{"query":"find _integrations"}

Privacy Controls

Built-in data protection

When your app stores user data, privacy rules require the ability to erase, hide, and manage how long data is kept. InfiniteOcean handles this at the storage level — real deletion everywhere, not just hiding data behind a flag.

Right to erasure: Permanently deletes all records matching a folder prefix. Files are physically removed from all storage. Indexes are rewritten without the erased entries.

Selective redaction: Replaces specific fields with [REDACTED] across all matching records. Original values are destroyed — history is rewritten too.

Retention policies: Sets an auto-deletion window (in days) for a folder prefix. Records older than the retention period are automatically purged.

All three operations require your key with admin access and a prefix parameter to scope the operation.

Important: folder names and keys are structural

InfiniteOcean uses a tamper-proof chain for auditing. When data is erased, the content is physically deleted and the record becomes unsearchable — but the folder name, key, and timestamp remain as structural information. This means folder names and keys must never contain personal data. Use opaque identifiers (users/u_8f3a) instead of real names (users/john-doe). Keep all personal information inside the content, where it can be fully erased or hidden.

# Right to erasure — delete all user data
POST /gdpr/erase
{"prefix": "users/user-123"}

# Response
{"erased": 47, "prefix": "users/user-123"}

# Field-level redaction — remove PII
POST /gdpr/redact
{"prefix": "users/user-123",
 "fields": ["email", "phone", "address"]}

# Response
{"redacted": 12, "fields": ["email","phone","address"]}

# Set retention policy — auto-delete after 90 days
POST /gdpr/retention
{"prefix": "logs/analytics",
 "retention_days": 90}

# Response
{"prefix": "logs/analytics", "retention_days": 90}

Email

Send and receive email

Full email built in. Claim a mailbox, send and receive email, add custom domains. Every email is stored as a record with smart fields — searchable, queryable, and works with everything else.

Get started: Open the Dashboard, go to the Mail tab, and claim your username. Or use Drops below.

Claim a subdomain: provisions [email protected] automatically. All email authentication is configured for you. Costs 100 io.

Unlimited addresses: Any local part works — hello@, invoices@, support@ all route to the same inbox.

Inbound: Received emails appear as records in mail/{username}/inbound with full body, headers, and smart fields.

Custom domains: Register your own domain via POST /mail/domain (HTTP). Add DNS records at your registrar, then verify. Costs 500 io.

-- Send an email
mail to "[email protected]" subject "Hello from the ocean" body "Hey Bob!"

-- Send with from address
mail from "[email protected]"
  to "[email protected]"
  subject "Hello"
  body "Hey Bob!"

-- Read your inbox
find mail/alice/inbound sort created_at desc limit 20

-- Search your mail
search mail/alice/inbound "invoice" top 10

-- List your domains (HTTP)
-- GET /mail/my-domains

-- Claim mailbox (HTTP)
-- POST /mail/claim { username: "alice" }

-- Custom domains (HTTP)
-- POST /mail/domain { domain: "mycoolsite.com" }
-- POST /mail/domain/verify { domain: "mycoolsite.com" }

Linked Records

Reference content from other records with {"$embed": {...}} in your data. The server resolves them when you read — the original creator keeps ownership, view tracking, and monetization.

Like YouTube embeds: you reference, they serve. The linked record remains fully owned by the original creator — their counters increment, their pricing applies, and the link is always live.

-- 1. Create source content
save photos/nature @sunset url: "https://cdn.example.com/sunset.jpg"

-- 2. Embed it in a gallery (use $embed in payload)
save gallery @featured {
  title: "Featured",
  hero: { "$embed": { route: "photos/nature", key: "sunset" } }
}

-- 3. Read — hero is auto-resolved
get gallery @featured
-- payload.hero = { $embedded: true, mode: "data",
--   payload: { url: "https://cdn..." } }

-- Live embed — skip 5-min cache
save dashboard @main {
  ticker: { "$embed": { route: "data/feed", key: "ticker", live: true } }
}

Embed options

FieldTypeDescription
routestringSource record's folder (required)
keystringSource record's key (required)
modestring"data" (default, full content) or "view" (URL + summary only)
livebooleanBypass 5-minute cache for real-time freshness

Monetization

Field on sourceModelBehavior
priceOne-timeReader pays once via POST /purchase, embeds resolve forever
price_per_readPer-readEach time the linked content is loaded, the reader is charged. Creator earns continuously.

Controls


Calendar

Any record with start + title is a calendar event. Calendar feeds work on any folder — subscribe from Google Calendar, Apple Calendar, or Outlook. Invite emails include native Accept/Decline buttons. Responses are saved on the event.

Events live on whatever folder you choose. Calendar is a convention, not a separate system — the same query, search, and smart fields you use for everything else work on events too.

-- Create an event with invites
event calendar/team {
  title: "Sprint planning",
  start: "2026-03-10T10:00:00Z",
  end: "2026-03-10T11:00:00Z",
  location: "Room 3",
  organizer: "[email protected]",
  attendees: (
    { email: "[email protected]" },
    { email: "[email protected]" }
  ),
  send_invites: true
}

-- List events on a route
events calendar/team

-- List events with date range
events calendar/team start "2026-03-01" end "2026-04-01"

RSVP & Feeds

Set send_invites: true with an organizer and attendees to send invite emails. Gmail, Outlook, and Apple Calendar show native Accept/Decline buttons.

Calendar feeds: Subscribe to any folder from your calendar app. The feed URL is available via the calendar feed command.

-- RSVP to an event
rsvp calendar/team @sprint-42 "[email protected]" accepted

-- Get calendar feed URL
calendar feed calendar/team
-- { feed_url: "https://api.infiniteocean.io/
--   calendar/feed/calendar/team?token=abc..." }

-- Paste feed_url into Google Calendar:
-- Settings → Add calendar → From URL

Event fields

FieldTypeDescription
titlestringEvent title (required)
startstringISO 8601 start time (required). Date-only for all-day events.
endstringISO 8601 end time
descriptionstringEvent description
locationstringEvent location
organizerstringOrganizer email (required for invites)
attendeesarray[{ "email": "...", "status": "needs-action" }]
rrulestringRecurrence rule (e.g. FREQ=WEEKLY;BYDAY=MO)
statusstringconfirmed, tentative, or cancelled
sequencenumberAuto-incremented on update (tracks event changes for calendar apps)

Browser Notifications

Native browser push notifications built in. No Firebase, no external service — everything runs inside InfiniteOcean.

Two models: explicit send (notify specific users by key) and folder subscriptions (auto-notify when new data is saved to a matching folder — like triggers but delivered to the browser).

Expired subscriptions are automatically cleaned up. Free — no io charge.

-- Send a push notification
push send {
  to: ("target-ocean-key"),
  title: "New message",
  body: "You have a new message",
  url: "https://myapp.com/messages/123"
}

-- Subscribe to route notifications
push subscribe myapp/orders

-- List your subscriptions
push subscriptions

-- Unsubscribe
push unsubscribe "subscription-id"

Browser Integration

Browser subscription registration uses standard browser APIs:

// Register service worker + subscribe to push
const reg = await navigator.serviceWorker.register('/sw.js');
const vapid = await fetch('https://api.infiniteocean.io/push/vapid-key')
  .then(r => r.json());

const sub = await reg.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(vapid.publicKey)
});

// Send subscription to InfiniteOcean
const { endpoint, keys } = sub.toJSON();
await fetch('https://api.infiniteocean.io/push/subscribe', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Ocean-Key': YOUR_KEY
  },
  body: JSON.stringify({ endpoint, keys })
});

Real-time Streams

Some data is useful in the moment but not historically — a now-playing track, presence pings, sensor readings, typing indicators. Real-time streams broadcast to live listeners without persisting anything: no chain entry, no manifest, no S3, no disk. Full reference →

Two ways to use it:

  • POST /broadcast/:route — explicit one-shot publish.
  • Stream-type routes — mark a route once, and every POST /drop on it is auto-routed to broadcast. Existing client code keeps working; the platform just stops persisting.

Listeners subscribe via SSE on GET /broadcast/:route/listen. Cross-server fan-out is automatic — a listener on any node receives broadcasts published on any node.

Need a permanent record of a specific session? Toggle recording on for a stream-type route and drops are also persisted under :route/recordings/:key until you toggle it off. Best of both worlds: ephemeral by default, durable when you want it.

// Mark a route as a stream (one-time setup)
POST /drop
{
  "route":   "_config/myapp/now-playing",
  "key":     "stream",
  "payload": { "type": "stream" }
}

// Publish (existing /drop client code, no change needed)
POST /drop
{ "route": "myapp/now-playing",
  "payload": { "track": "Whatever - Whoever" } }
// → {"ok":true,"stream":true,"recording":false}

// Listen (browser)
const ev = new EventSource(
  'https://api.infiniteocean.io/broadcast/myapp/now-playing/listen?key=' + KEY
);
ev.onmessage = (e) => console.log(JSON.parse(e.data));

// Record this episode
POST /record/start
{ "route": "myapp/now-playing",
  "key":   "ep-42" }
// drops now ALSO land in myapp/now-playing/recordings/ep-42/

POST /record/stop
{ "route": "myapp/now-playing" }
// back to ephemeral

Values & Tokens

Create your own currency or token. Any user can mint a new value with a unique symbol, then distribute it to other keys. Built-in ledger tracks every mutation.

Transfer fee: 0.1% platform fee on every transfer. The sender pays the fee on top — the recipient always receives the exact amount. Example: transfer 100, sender pays 100 + 1 fee, recipient gets 100.

Scope: public (anyone can receive) or private (only existing holders or the creator can receive transfers).

Supply can be capped with max_supply. Use decimals for fractional tokens (e.g. decimals: 2 means 100 = 1.00).

// Create a currency
value create "gold" name "Gold" symbol "GLD"

// With options
value create "gold" name "Gold" symbol "GLD" max_supply 1000000 decimals 2 scope "private"

// Mint (creator only)
value mint "gold" 1000
value mint "gold" 500 to "recipientKey"

// Transfer (0.1% fee)
value transfer "gold" 100 to "recipientKey"

// Burn from your balance
value burn "gold" 50

// Check balance, supply, holders, ledger
value balance "gold"
value supply "gold"
value currencies
value holders "gold"
value ledger "gold"
// HTTP endpoints
POST /values/create   { id, name, symbol, scope, max_supply, decimals }
POST /values/mint     { currency, amount, to }
POST /values/transfer { currency, amount, to }
POST /values/burn     { currency, amount }
GET  /values/balance?currency=X&holder=Y
GET  /values/currencies?creator=X
GET  /values/currency/:id
GET  /values/holders?currency=X
GET  /values/supply?currency=X
GET  /values/ledger/:id?limit=N

Templates & Saved Queries

Define the shape of your data with named fields and validation rules. Attach HTML templates that render records automatically. Structured data that validates on save and displays on read.

Patterns are regular records in the patterns folder. View templates are HTML records referenced by drop:// URIs. The view system at view.infiniteocean.io resolves patterns, loads templates, and renders the final HTML.

-- Define a pattern (simple)
define todo fields title: "text" status: "text" due: "date"

-- Define with detailed rules (braces for nesting)
define todo version "1" fields {
  title:  { type: "text", required: true },
  status: { type: "text", enum: ("pending", "done") },
  due:    { type: "date" },
  tags:   { type: "text", array: true }
}

-- List all patterns
patterns

Validate your data

Check data against a pattern before saving. Returns { valid, errors }.

Validation checks: required fields present, values match declared types, enum fields contain allowed values, array fields are arrays.

-- Validate data against a pattern
validate todo title: "Ship v2" status: "pending"
-- { valid: true, errors: [] }

-- Save a validated record
save todos @ship-v2 pattern: "todo" title: "Ship v2" status: "pending" due: "2026-04-01" tags: ("release", "important")

-- View at: view.infiniteocean.io/todos/ship-v2

Pattern views

When a record has a pattern field, the view system renders it with a template:

  1. Load the pattern definition from patterns/{name}
  2. Read the template HTML from pattern.views[mode]
  3. Fill in the values, expand lists, and hide missing fields

Template syntax: {{field}} for values, data-ref="field" for references, data-array="field" for lists. Elements with unfilled placeholders are automatically hidden.

View modes: ?mode=full (default) and ?mode=preview (compact).

-- Write a view template
save views @"todo/full" "<article><h1>{{title}}</h1><p>{{description}}</p><time>{{due}}</time><div data-array='tags'>Tags</div></article>"

Field types

TypeDescriptionOptions
textFree-form text stringrequired, array, enum
dateISO 8601 date or datetimerequired, array
identifierEmail, URL, phone, or other IDrequired, array
blobBinary data reference (file path or URL)required, array
locationGeographical location (lat/lon or address)required, array
amountNumeric amount with optional unitrequired, array
relationReference to another entity ($embed)required, array

Error codes

SituationCodeStatus
Missing folder when savingROUTE_REQUIRED400
Invalid folder formatINVALID_ROUTE400
Invalid record keyINVALID_KEY400
Missing key when reading a recordKEY_REQUIRED400
Invalid search prefixINVALID_PREFIX400
Missing both vector and textSEARCH_REQUIRED400
Invalid vector formatINVALID_VECTOR400
Invalid text formatINVALID_TEXT400
Query dimensions != stored dimensionsDIMENSION_MISMATCH400
SQL parse or execution errorSQL_ERROR400
Data does not match the expected formatSCHEMA_VIOLATION400
top_k exceeds 1000TOP_K_TOO_LARGE400
Record not foundNOT_FOUND404
Record has been deletedDELETED404
No vectors at this locationNO_VECTORS404
No text content at this locationNO_TEXT_CONTENT404
Missing key on a private folderKEY_REQUIRED401
Key does not have accessFORBIDDEN403
No trust record for shared folderNO_TRUST403
Content over 10MBPAYLOAD_TOO_LARGE413
Single file upload over 100MB (use chunked)BLOB_TOO_LARGE413
File not foundNOT_FOUND404
Encoding job not foundNOT_FOUND404
Encoding failedENCODE_ERROR500
Invalid email on POST /auth/loginINVALID_EMAIL400
Magic link token invalid or usedINVALID_TOKEN400
Magic link token expiredTOKEN_EXPIRED400
Too many logins for this emailAUTH_RATE_LIMITED429
Not enough io balance (for priced content)RATE_LIMITED429
Too many key generations from IPKEYGEN_LIMITED429
Server at capacitySERVER_BUSY503
Server errorINTERNAL_ERROR500

Before going live: secure your folders

Open folders are great for prototyping. Before production, lock them down with a single command:

grant my/route to YOUR_KEY protected

Drops auto-protects routes on first write. Use write public to opt out.

Try it live

Try it live

This is real — every command runs against InfiniteOcean using a shared demo account. State is shared with all visitors of this page.

// Click a button above...