Automation API
The Automation API (/) is the headless document entry point — the surface the Flue agent platform and local automation clients use to list, read, search, create, update, delete, move, inspect history, restore versions, create/read checkpoints, and poll document changes without driving the app UI. It is mounted on the same Cloudflare Worker as the rest of the Sync Server API and shares its base URL and Bearer-token auth model.
Assets deliberately do not get plaintext routes here. Narrow asset clients use the existing opaque / family and perform path/content crypto locally; see Assets API.
Base URL: https:
At a high level, an automation client:
Mints a scoped Personal Access Token (PAT) — see Authentication.
Looks up the workspace's encryption salt — see Workspace Metadata.
Opens a key session by posting the workspace's derived decryption keys once — see Key Sessions.
Calls the document/history/change routes below, presenting the PAT as
Authorization: Bearerand the key session asX-Key-Sessionon every keyed workspace request.
If you just want to get a note read and written from the command line, skip to the curl quickstart.
Authentication
Personal Access Tokens (scoped)
Automation routes accept the same PATs documented in Personal Access Tokens, minted via POST /, but three things matter specifically for automation callers:
Scopes. A PAT minted without a
scopesarray defaults to["full"]— unrestricted, exactly like every PAT before scoped tokens existed. For an automation client, mint one with only the scopes it actually needs:Scope Grants documents:readDocument list/content/search/changes, version reads, and checkpoint projections below documents:writeDocument PUT/DELETE/move/version restore and checkpoint creation below agent:invokeAgent-server ingress; accepted by metadata lookup but not by keyed routes on this page assets:readOpaque asset list/usage/download; accepted by metadata lookup without the document subscription gate assets:writeOpaque asset upload/folder creation; does not by itself grant metadata lookup fullEvery narrow scope plus the wider compatibility surface A PAT holding
fullsatisfies every narrow scope check; a narrow PAT is rejected withFORBIDDEN_SCOPE(403) outside what it was minted with. Most non-automation administration/publish routes requirefull, while the existing opaque Assets API has its own method-specific asset grants.GET /(Workspace Metadata) acceptsapi/ v1/ automation/ workspaces/ : workspaceId/ meta documents:read,documents:write,agent:invoke,assets:read, orfull. It intentionally does not acceptassets:writealone.Expiry.
expiresAt(optional, ISO 8601, must be in the future) makes the token stop authenticating automatically once it passes — no manual revocation needed for a bounded-duration automation job.Workspace binding.
workspaceId(optional) confines the token to one workspace. A workspace-bound PAT presented against a different workspace it happens to own getsNOT_FOUND(404) on every automation route, including at key-session mint time — the server never confirms or denies that the other workspace exists.
Interactive JWT
A Better Auth session JWT (or dev-login JWT) always behaves as if it held every scope (hasScope() short-circuits for non-PAT auth) and is never workspace-bound. Interactive auth is not the intended caller for this API, but nothing on this page rejects it.
GET /api/v1/auth/introspect
Self-describes the presented Bearer token — the mechanism the agent-server uses (over a service binding) to learn a caller's scopes and workspace binding before dispatching an automation request, without ever duplicating PAT verification logic into another Worker. Exempt from the full-scope gate that guards every other non-automation route, since a scoped-only PAT must be able to introspect itself.
Response (PAT):
{
"userId": "user-uuid",
"scopes": ["documents:read", "documents:write"],
"workspaceId": "workspace-uuid",
"tokenKind": "pat"
}Response (interactive session):
{
"userId": "user-uuid",
"scopes": ["full"],
"workspaceId": null,
"tokenKind": "jwt"
}Workspace Metadata
GET /api/v1/automation/workspaces/:workspaceId/meta
Returns the encryption salt a client needs before deriving keys. This route takes no X-Key-Session. It accepts documents:read, documents:write, agent:invoke, assets:read, or full; assets:write alone is insufficient.
Callers that do not explicitly hold assets:read — including document/agent/full-only PATs and interactive callers — keep the normal subscription, workspace-binding, and ownership checks. A PAT explicitly holding assets:read takes a narrower metadata-only exception: workspace binding and ownership still apply, but an active document subscription is not required. The response contains only encryptionSalt; it opens no key session and grants no keyed document access. This lets an asset-only client on a free account derive local non-extractable asset keys before calling opaque asset routes.
Response (200):
{ "encryptionSalt": "9f2c..." }encryptionSalt is null until the workspace's one-time encryption setup has run (Settings → Cloud Sync, first unlock) — if you see null, unlock the workspace once in the app before scripting against it.
Key Sessions
Why a key session exists
A workspace is end-to-end encrypted: file content, HMAC integrity tags, and paths are all encrypted client-side, and the server normally never holds decryption-capable key material. The automation routes below need to read and write plaintext on the caller's behalf, so before any list/read/search/write call, the client derives its three raw 32-byte sub-keys (encryption, HMAC, path — see step 3 of the quickstart for exactly how) and posts them once to open a session.
Caution
Opening a key session is a deliberate, narrow exception to the workspace's zero-knowledge design — the exact tradeoff the Personal Access Tokens security-boundary note carves out. For that session's lifetime, the server holds decryption-capable keys in Durable Object memory only — never written to disk or state.storage — and evicts them automatically within one hour even if nothing goes wrong. This is opt-in per session, scoped to one workspace, and bounded by the session TTL; it is not a standing capability granted by minting a PAT. Any UI that lets a user trigger this exchange (a consent dialog before keys are posted) must state the tradeoff plainly before the keys leave the device. What an AI agent subsequently does with the plaintext it reads — including how much conversation history it retains or discloses — is a separate concern owned by the agent-facing docs, not by this wire contract.
POST /api/v1/automation/key-sessions
Opens a key session for one workspace. Requires no particular scope (any authenticated PAT or JWT may call it; the routes that use the session still enforce their own scopes).
Request:
{
"workspaceId": "workspace-uuid",
"encryptionKeyB64": "base64-32-raw-bytes",
"hmacKeyB64": "base64-32-raw-bytes",
"pathKeyB64": "base64-32-raw-bytes"
}workspaceId is required. Each of the three key fields must base64-decode to exactly 32 raw bytes. A workspace-bound PAT presented with a workspaceId other than the one it's bound to, or a workspace the caller doesn't own, gets NOT_FOUND (404) rather than a validation error — the same don't-leak-existence rule the data routes below follow.
Response (201):
{
"keySessionId": "uuid",
"expiresAt": 1798320000000
}expiresAt is a Unix-ms timestamp, one hour (KEY_SESSION_TTL_MS) from creation.
DELETE /api/v1/automation/key-sessions/:id
Idempotent teardown — deleting an unknown or already-expired session id is a no-op success, not a 404.
Response:
{ "ok": true }Presenting the session
Every read/write route below requires an X-Key-Session header carrying the keySessionId from the mint response:
X-Key-Session: <keySessionId>An unknown, expired, or hibernated session id responds KEY_SESSION_EXPIRED (401, retryable). Treat this as an expected, recoverable condition — re-derive and re-post the keys via POST / and retry, rather than a fatal error. Sessions are evicted two ways: a Durable Object alarm sweeps every session past its expiresAt, and every read/delete also lazily evicts on the spot — so eviction happens even if the alarm silently fails to fire (hibernation edge cases, redeploys).
userId/workspaceId are checked defense-in-depth on every session read, even though the session store is already a per-user Durable Object instance (idFromName(userId)) — a belt-and-suspenders guard against resolving the wrong instance or naming the wrong workspace, not the primary access control.
Rate limiting
Every route in this group — key-session create/delete and every data route below — shares one fixed-window rate limiter per user: 120 requests/minute, counted against the same Durable Object instance the user's key sessions live in (not per-route). Exceeding it responds RATE_LIMITED (429, retryable) with a retryAfter (seconds until the window resets).
Workspace Access Gating
Every keyed document route under / applies the same gate, in this order, before doing any work:
Scope — the PAT (or JWT) must hold the route's required scope, or
FORBIDDEN_SCOPE(403).Workspace binding — a workspace-bound PAT presented against a different workspace gets
NOT_FOUND(404).Active subscription —
hasActiveSub()must pass, or the request gets typedSUBSCRIPTION_REQUIREDat402.Workspace ownership — the workspace must exist and belong to the caller, or
NOT_FOUND(404).Key session — a live session for this workspace, via
X-Key-Session, orKEY_SESSION_EXPIRED(401).
GET /workspaces/:workspaceId/meta is the exception described above: it has no key-session step, and an explicit assets:read PAT uses binding + ownership without the document subscription check.
Note
Decision: keyed document reads are subscription-gated, unlike the plain file routes. Step 3 above applies to list/search/read/history/change/checkpoint document routes. This is deliberately stricter than the equivalent plain encrypted-blob routes (GET /, GET /), which do not check subscription status on reads — only their PUT/DELETE counterparts do. Once a subscription lapses, keyed document calls return typed SUBSCRIPTION_REQUIRED (402). Key-session lifecycle routes and the explicit assets:read metadata exception have the narrower gates documented above.
Read Routes
Every route in this section requires documents:read or full scope.
Note
createdAt, updatedAt, and currentUpdatedAt are ISO-8601 UTC, Z-suffixed, millisecond precision (e.g. 2026-04-06T10:30:00.000Z) — these three fields are normalized at the API edge before they're serialized, so a standard ISO-8601 parser handles them correctly with no manual UTC handling. This covers createdAt/updatedAt on the routes below and currentUpdatedAt in the Error Envelope's VERSION_CONFLICT details.
GET /api/v1/automation/workspaces/:workspaceId/documents
List a workspace's live (non-deleted) documents, sorted by canonical (plaintext) path, keyset-paginated.
Headers: X-Key-Session: <keySessionId>
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
prefix | string | none | Plaintext path prefix filter. Runs through the same path-safety rules as a full path (rejects .., absolute paths, null bytes, backslashes). A trailing slash is preserved, so drafts/ filters to that directory rather than also matching drafts2/…. |
limit | string (numeric) | 100 | Page size. Clamped to [1, 500]; non-numeric/absent/non-finite falls back to the default; fractional values are floored. |
cursor | string | none (first page) | The previous page's nextCursor — an exclusive lower bound over plaintext path. |
Response:
{
"documents": [
{
"path": "drafts/monday.md",
"version": 3,
"createdAt": "2026-04-06T10:00:00.000Z",
"updatedAt": "2026-04-06T10:30:00.000Z",
"size": 1408
}
],
"nextCursor": null,
"changeCursor": 45
}Note
Decision: size here is ciphertext length, not plaintext length. It is a straight read of D1's encrypted_size column (version byte + IV + AES-GCM auth tag + HMAC + ciphertext) — decrypting every file on a page just to report an exact byte count would defeat the point of a cheap, bounded listing endpoint. It is always strictly larger than the plaintext. If you need the exact plaintext size, call GET .../documents/content, which already decrypts the one file it reads and reports the real value.
GET /api/v1/automation/workspaces/:workspaceId/documents/content
Read one document's exact plaintext, by workspace-relative path.
Headers: X-Key-Session: <keySessionId>
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
path | string | — (required) | Workspace-relative plaintext path, forward slashes, no leading /, no .. segments. |
maxBytes | string (numeric) | 1048576 (1 MiB) | Read window, not a limit the caller can violate: a longer document comes back truncated to it. Clamped to [1, 8388608] (8 MiB). |
Response:
{
"path": "drafts/monday.md",
"version": 3,
"createdAt": "2026-04-06T10:00:00.000Z",
"updatedAt": "2026-04-06T10:30:00.000Z",
"size": 87,
"totalBytes": 87,
"truncated": false,
"content": "# Monday standup notes\n\n...",
"changeCursor": 45
}size is the exact plaintext byte length of the content returned — contrast with the list route's ciphertext-length size above. totalBytes is the length of the whole document, so a short file and a cut-off one are distinguishable without a second read, and truncated says which happened. A cut never lands mid-character: a partial trailing multi-byte sequence is dropped rather than replaced with U+FFFD. changeCursor is captured before the document read.
PAYLOAD_TOO_LARGE (413) is reserved for a document past the absolute 8 MiB read ceiling — the one size no maxBytes makes readable. Asking for less does not help there, which is exactly why exceeding maxBytes is no longer reported the same way. A missing file responds NOT_FOUND (404).
GET /api/v1/automation/workspaces/:workspaceId/search
Structured (JSON) regex search over a workspace's documents, under the validated pattern rules and per-call scan limits in src/ (ReDoS heuristic guard, pattern-length cap, file-scan cap, result cap).
Headers: X-Key-Session: <keySessionId>
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
q | string | — (required) | A JavaScript regular expression, max 256 characters, rejected if it fails a ReDoS heuristic check or fails to compile. |
mode | files | content | count | files | See response shapes below. |
prefix | string | none | Same plaintext path-prefix filter as the list route. |
limit | string (numeric) | 100 | Max results to collect for the active mode. Clamped to [1, 500]. |
Response (mode=files):
{
"mode": "files",
"paths": ["drafts/monday.md"],
"scannedFiles": 12,
"totalFiles": 12,
"truncated": false,
"changeCursor": 45
}Response (mode=content):
{
"mode": "content",
"matches": [{ "path": "drafts/monday.md", "line": 4, "excerpt": "foo bar baz" }],
"scannedFiles": 12,
"totalFiles": 12,
"truncated": false,
"changeCursor": 45
}Response (mode=count):
{
"mode": "count",
"counts": [{ "path": "drafts/monday.md", "count": 2 }],
"scannedFiles": 12,
"totalFiles": 12,
"truncated": false,
"changeCursor": 45
}truncated is true when the result set was cut short by limit, by the 2,000-file scan cap (MAX_FILES_SCANNED), or by the 240-document content-read cap (MAX_CONTENT_READS_PER_SEARCH) — see Limits. scannedFiles/totalFiles let a caller tell "found nothing" apart from "didn't finish scanning".
List, content, and search capture changeCursor before reading their state. A caller can consume GET .../changes from that watermark without a concurrent write falling through the gap.
GET /api/v1/automation/workspaces/:workspaceId/changes
Poll ordered document changes after a document-log watermark. This is an indexed delta query, not a list scan and not an asset feed.
| Param | Type | Default | Description |
|---|---|---|---|
afterCursor | non-negative integer | 0 | Exclusive lower bound over changes.id. |
limit | numeric string | 100 | Page size, clamped to [1, 1000]. Pass the returned cursor when hasMore is true. |
{
"resource": "documents",
"changes": [
{
"changeCursor": 46,
"fileId": "file-uuid",
"path": "drafts/monday.md",
"action": "upsert",
"contentHash": "sha256-hex",
"encryptedSize": 1408,
"version": 4,
"changedAt": "2026-04-06 10:31:00",
"changedByDevice": "automation-device-id"
}
],
"changeCursor": 46,
"hasMore": false
}Actions remain exactly upsert | delete. A document move produces an ordered old-path delete followed by a new-path upsert. Assets never increment or appear in this cursor. When compaction expires the requested watermark, the route returns typed CHANGE_CURSOR_EXPIRED (409) with requestedCursor and compactionFloor details. There is no local-agent WebSocket feed in v1.
Write Routes
Every route in this section requires documents:write or full scope, and an Idempotency-Key header — see Idempotency.
PUT /api/v1/automation/workspaces/:workspaceId/documents
Create or update a document, by workspace-relative path, with optimistic-concurrency version checking.
Headers:
X-Key-Session: <keySessionId>
Idempotency-Key: <opaque-client-generated-key>Query params:
| Param | Type | Description |
|---|---|---|
path | string (required) | Workspace-relative plaintext path. |
expectedVersion | string (numeric, optional) | Absent → create; fails VERSION_CONFLICT if a live file already exists at path. Present (must be a positive integer) → update; fails VERSION_CONFLICT unless it matches the file's current version exactly. |
Request body:
{ "content": "# Monday standup notes\n\nUpdated content here." }Plaintext content, capped at 1 MiB — see Limits.
Response (create, 201):
{ "path": "drafts/monday.md", "version": 1, "isNew": true }Response (update, 200):
{ "path": "drafts/monday.md", "version": 4, "isNew": false }Response (409, version conflict):
{
"code": "VERSION_CONFLICT",
"requestId": "uuid",
"message": "Version conflict: drafts/monday.md",
"retryable": false,
"details": { "currentVersion": 3, "currentUpdatedAt": "2026-04-06T10:30:00.000Z", "currentSize": 1408 }
}details.currentSize is the ciphertext length (same convention as the list route). details never carries file content. When expectedVersion was given but no live file exists, details is { "currentVersion": null } instead.
DELETE /api/v1/automation/workspaces/:workspaceId/documents
Tombstone (soft-delete) a document. Unlike PUT, expectedVersion is required here, not optional — a delete with nothing to check against would silently remove whatever currently lives at that path, defeating the optimistic-concurrency contract the rest of this surface relies on.
Headers:
X-Key-Session: <keySessionId>
Idempotency-Key: <opaque-client-generated-key>Query params:
| Param | Type | Description |
|---|---|---|
path | string (required) | Workspace-relative plaintext path. |
expectedVersion | string (numeric, required) | Must match the file's current version exactly, or VERSION_CONFLICT (409). |
Response (200):
{ "path": "drafts/monday.md", "version": 4, "ok": true }A missing file responds NOT_FOUND (404). The delete is visible to regular sync clients on their next POST / as an action: "delete" change row, and — like PUT above — fires a real-time sync-room notification and Web Push so open clients don't sit waiting for a manual pull.
POST /api/v1/automation/workspaces/:workspaceId/documents/move
Atomically move one live document while retaining its stable files.id, version history, content hash, and current content version.
Headers: X-Key-Session and Idempotency-Key.
{
"fromPath": "drafts/monday.md",
"toPath": "archives/monday.md",
"expectedVersion": 4
}{
"fileId": "file-uuid",
"fromPath": "drafts/monday.md",
"toPath": "archives/monday.md",
"version": 4,
"changeCursor": 48
}The transaction appends an old-path delete then a new-path upsert and emits one SyncRoom notification carrying the final cursor. It does not create a content version or add a move action. Typed failures distinguish MOVE_SOURCE_NOT_FOUND, MOVE_DESTINATION_CONFLICT, VERSION_CONFLICT, and a concurrent MOVE_CONFLICT.
History and Checkpoint Routes
These routes reuse the same documents:read/documents:write, workspace, subscription, ownership, and key-session gates. They cover documents only; assets have no versions, checkpoints, restore manifest, or change cursor.
| Method and route | Scope | Contract |
|---|---|---|
GET / | documents:read | List versions of the stable file currently addressed by path, including versions from before a move. |
GET / | documents:read | Return complete decrypted content for one version belonging to that stable file. |
POST / | documents:write | Restore one version as a new head. Body: { path, versionId, expectedVersion }; Idempotency-Key is required. |
POST /workspaces/:workspaceId/checkpoints | documents:write | Create a manual checkpoint from plaintext { label }; the key session encrypts the label. This operation is not idempotent. |
GET /workspaces/:workspaceId/checkpoints | documents:read | List decrypted checkpoint markers with resource: "documents". |
GET / | documents:read | Return the document-only change summary projection. |
GET / | documents:read | Return restoreMode: "client-orchestrated", expectedHeadCursor, and path/file-id work entries. |
GET / | documents:read | Return one plaintext path's document state at the checkpoint. |
Per-file version restore is the only server-backed restore operation. A checkpoint is a named changes.id cursor, not a snapshot, and there is no checkpoint-restore endpoint. A rollback client fetches the manifest, pages GET .../changes?afterCursor=0 until hasMore is false (passing each page's changeCursor as the next afterCursor), compares the final cursor with expectedHeadCursor, then applies normal optimistic operations. Conflicts can leave partial progress; clients stop and refetch rather than forcing the rest.
Checkpoint projections return typed HISTORY_COMPACTED (409) whenever the change log cannot support a complete replay. Compaction stays disabled while checkpoints exist.
Idempotency
Document PUT, DELETE, move, and version restore require an Idempotency-Key header. The key is scoped to (userId, key) and its outcome is recorded against a SHA-256 fingerprint of the exact (method, url, body) triple (the URL already carries query parameters, so two otherwise-identical requests that differ only there fingerprint differently):
First call with a fresh key — CLAIMS the key, executes, then records the result (success or a typed error like
VERSION_CONFLICT). The claim happens before the write, not after it, so the whole execution window is guarded rather than just its aftermath.Retry with the SAME key and the SAME fingerprint, after the first call finished — replays the first execution's recorded
{ status, body }verbatim, without re-running the write. This is what makes an at-least-once retry (network timeout, an agent tool-loop retry) safe.Second request with the SAME key and the SAME fingerprint, while the first is still running — rejected as
IDEMPOTENCY_CONFLICT(409) withretryable: true. Waiting a moment and asking again gets the real answer; it is not a permanent refusal.Retry with the SAME key but a DIFFERENT fingerprint — rejected as
IDEMPOTENCY_CONFLICT(409),retryable: false, rather than silently executing (or silently replaying) the wrong request. This is the signal that a client is reusing a key across two genuinely different requests.
If a VERSION_CONFLICT causes the caller to change the payload or precondition, that follow-up is a different fingerprint and must use a fresh idempotency key. Reuse the old key only for an exact retry of the original operation.
A transient storage failure on delete (UNAVAILABLE, 503) is deliberately not recorded in the ledger, and its claim is released, so a retry with the same key re-attempts the write rather than replaying a failure that never actually committed. A claim whose request died mid-flight without releasing anything is taken over after ~60 seconds.
A replayed response carries the ORIGINAL request's requestId, since the whole { status, body } pair is replayed byte-for-byte. Two calls that look identical to a client can therefore report the same id — that id identifies the execution, not the HTTP request that returned it.
Ledger rows are retained for ~7 days, purged opportunistically (a bounded DELETE ... LIMIT 500) on every successful write rather than by a scheduled job — a key reused after that window is treated as fresh.
Error Envelope
Every automation route responds to failures with this typed shape, so a client can branch on code instead of parsing prose:
interface AutomationErrorBody {
code: string;
requestId: string;
message: string;
retryable: boolean;
retryAfter?: number; // seconds; only meaningful when retryable
details?: Record<string, unknown>; // code-specific safe metadata; never content
}| Code | HTTP status | Retryable | When |
|---|---|---|---|
UNAUTHORIZED | 401 | No | Reserved for shape-consistency with the rest of the codes; not currently emitted by any route on this page — a missing/invalid Bearer token is rejected earlier, by the shared authMiddleware before the request reaches the automation route group, with the plain { "error": "Unauthorized" } shape every other route on this server uses. |
FORBIDDEN_SCOPE | 403 | No | The presented token lacks the route's required scope. |
SUBSCRIPTION_REQUIRED | 402 | No | A keyed document route requires an active subscription. The explicit assets:read metadata exception does not. |
NOT_FOUND | 404 | No | Workspace doesn't exist / isn't owned by the caller / is denied by a workspace-bound PAT's binding; or a document path doesn't exist. |
VALIDATION_FAILED | 400 | No | Malformed body, missing/unsafe path, invalid expectedVersion, missing Idempotency-Key, bad key-session bytes, etc. |
KEY_SESSION_EXPIRED | 401 | Yes | X-Key-Session is missing, unknown, or its TTL elapsed. Re-open a session and retry. |
VERSION_CONFLICT | 409 | No | expectedVersion (or its absence, for create) didn't match the file's actual current state. details carries safe metadata — never content. |
IDEMPOTENCY_CONFLICT | 409 | Sometimes | The same Idempotency-Key was reused with a different request fingerprint (retryable: false), or the identical request is still in flight (retryable: true — retry to collect its result). |
HISTORY_COMPACTED | 409 | No | A checkpoint projection cannot be computed from a compacted change log. details.compactionFloor identifies the floor. |
MOVE_SOURCE_NOT_FOUND | 404 | No | The move source is not live. |
MOVE_DESTINATION_CONFLICT | 409 | No | The destination namespace is occupied; safe destination state appears in details. |
MOVE_CONFLICT | 409 | No | The source moved concurrently; refresh before trying a new operation. |
CHANGE_CURSOR_EXPIRED | 409 | No | The requested document cursor predates the retained log; details include the requested cursor and compaction floor. |
PAYLOAD_TOO_LARGE | 413 | No | The document is past the absolute 8 MiB read ceiling, or write content exceeded the 1 MiB cap. Exceeding maxBytes on a read is NOT this — it truncates. |
RATE_LIMITED | 429 | Yes | Per-user budget (120 req/min) exceeded. retryAfter is set. |
UNAVAILABLE | 503 | Yes | Transient storage failure (currently only on delete's tombstone write). |
Limits
| Limit | Value | Applies to |
|---|---|---|
| Write content cap | 1 MiB (1,048,576 bytes) | PUT body. Exceeding it is a refusal (PAYLOAD_TOO_LARGE), not a truncation — a partial write would corrupt the document. |
| Read default window | 1 MiB | GET .../documents/content when maxBytes is omitted. Truncates, never refuses. |
| Read max window | 8 MiB | GET .../documents/content maxBytes ceiling — a larger value is clamped to it. |
| Absolute read ceiling | 8 MiB | The one read size that refuses: a document longer than this responds PAYLOAD_TOO_LARGE at any maxBytes. |
| List page size | 100 default, 500 max | GET .../documents limit. |
| Change page size | 100 default, 1,000 max | GET .../changes limit. |
| Search result cap | 100 default, 500 max | GET .../search limit (MAX_CONTENT_MATCHES). |
| Search pattern length | 256 characters | GET .../search q. |
| Search scan cap | 2,000 files | GET .../search — how far down the candidate list one call will look at all. Files beyond it are reported via truncated: true, not silently dropped from every future page (unlike the list route, which is unbounded, since decrypting a path is far cheaper than decrypting content). |
| Search content-read cap | 240 documents | GET .../search — how many candidates one call actually READS. Each read costs three subrequests, so this is what keeps a large workspace inside Cloudflare's per-request subrequest ceiling. Whichever cap binds first, the answer says truncated: true. |
| Rate limit | 120 requests/minute | Every route in this group, per user, fixed window. |
| Path length | 512 characters | Every path/prefix param. |
| Idempotency retention | ~7 days | Idempotency-Key ledger rows. |
| Key session TTL | 1 hour | X-Key-Session. |
Quickstart: read and write a note with curl
This walks through minting a scoped token, opening a key session, and doing a full list → read → write round trip with curl. It assumes you already have a zudo-text account with an E2E-encrypted workspace set up (Settings → Cloud Sync).
1. Mint a token in Settings → Access Tokens
Token management (mint/list/revoke) always requires an interactive sign-in session — a PAT can never mint another token, even one holding full scope (see Personal Access Tokens). So this one step happens in the app, not curl:
Open Settings → Access Tokens → New token.
Name it. The UI preselects document + asset authoring; for this document-only walkthrough you may leave that narrow default or deselect the two asset scopes.
documents:read+documents:writecover every step here. Do not choosefull.Copy the raw token value — it's shown exactly once. That's your
$PATfor every call below. Also note your workspace'sidfrom Settings → Cloud Sync as$WORKSPACE_ID.
2. Get your workspace's encryption salt
curl -s https://zudo-sync-server.takazudo.workers.dev/api/v1/automation/workspaces/$WORKSPACE_ID/meta \
-H "Authorization: Bearer $PAT" | jq{ "encryptionSalt": "9f2c..." }Note encryptionSalt as your $SALT_HEX.
3. Derive the workspace's keys
The easiest path is the versioned zudotext-mcp npm package — the MCP server wraps key derivation and session management for you, so an MCP-speaking agent client never touches raw key bytes directly.
For the raw wire flow (useful for a non-MCP client, or just to see what's happening under the hood): the three sub-keys are PBKDF2-SHA256 over your workspace passphrase, 600,000 iterations, using $SALT_HEX (hex-decoded) as salt, deriving a 96-byte master key split into three 32-byte sub-keys in order — encryption, HMAC, path — each base64url-encoded on the wire. From a zudo-text checkout, @takazudo/cloud-crypto's deriveRawKeysForSession implements exactly this:
pnpm --filter @takazudo/cloud-crypto build # once, if dist/ isn't built yet
node -e "
const { deriveRawKeysForSession } = require('./packages/cloud-crypto/dist/index.js');
(async () => {
const keys = await deriveRawKeysForSession(process.env.WORKSPACE_PASSWORD, process.env.SALT_HEX);
console.log(JSON.stringify(keys));
})();
"Save the three fields from the printed JSON as $ENC_KEY_B64, $HMAC_KEY_B64, $PATH_KEY_B64.
4. Open a key session
curl -s -X POST https://zudo-sync-server.takazudo.workers.dev/api/v1/automation/key-sessions \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d "{\"workspaceId\":\"$WORKSPACE_ID\",\"encryptionKeyB64\":\"$ENC_KEY_B64\",\"hmacKeyB64\":\"$HMAC_KEY_B64\",\"pathKeyB64\":\"$PATH_KEY_B64\"}"Save keySessionId from the response as $KEY_SESSION. It's valid for one hour.
5. List documents
curl -s "https://zudo-sync-server.takazudo.workers.dev/api/v1/automation/workspaces/$WORKSPACE_ID/documents" \
-H "Authorization: Bearer $PAT" \
-H "X-Key-Session: $KEY_SESSION" | jq6. Read a note
curl -s "https://zudo-sync-server.takazudo.workers.dev/api/v1/automation/workspaces/$WORKSPACE_ID/documents/content?path=drafts/monday.md" \
-H "Authorization: Bearer $PAT" \
-H "X-Key-Session: $KEY_SESSION" | jq7. Write a note
Create (no expectedVersion):
curl -s -X PUT "https://zudo-sync-server.takazudo.workers.dev/api/v1/automation/workspaces/$WORKSPACE_ID/documents?path=drafts/from-agent.md" \
-H "Authorization: Bearer $PAT" \
-H "X-Key-Session: $KEY_SESSION" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"content":"# Written by an agent\n\nHello from the automation API."}'Update (pass the version you just got back):
curl -s -X PUT "https://zudo-sync-server.takazudo.workers.dev/api/v1/automation/workspaces/$WORKSPACE_ID/documents?path=drafts/from-agent.md&expectedVersion=1" \
-H "Authorization: Bearer $PAT" \
-H "X-Key-Session: $KEY_SESSION" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"content":"# Written by an agent\n\nUpdated body."}'8. Tear down the session (optional)
curl -s -X DELETE "https://zudo-sync-server.takazudo.workers.dev/api/v1/automation/key-sessions/$KEY_SESSION" \
-H "Authorization: Bearer $PAT"Not strictly necessary — the session self-expires within an hour either way — but good hygiene once a scripted job is done.