API
The workspace holds folders and any file: metadata and live text in PostgreSQL, large bodies
optionally in object storage (storage.md). Everything is under /api,
authenticated with Authorization: Bearer <credential> (see Access).
- Live text: any file that is UTF-8 without NUL bytes and ≤ 2 MiB, whatever its extension
(
.md,.txt,.json,.csv, …).text: true,encoding: "utf8", real-time CRDT collaboration (state= base64 Yjs document,Y.Textnamedcontent), 3-way merges, line diffs, search. Markdown additionally gets links/backlinks. - Binary: everything else, up to 25 MiB — or
FLOCKFS_MAX_FILE_BYTES(default 5 GiB) with a blob store; files over 25 MiB go through the streaming endpoints (Large files).text: false,encoding: "base64",state: null. Versioned as whole-file snapshots; a stale revision is a conflict (no merge).
Requests that carry content accept encoding: "utf8" (default) or "base64".
| Method | Path | Body / query | Returns |
|---|---|---|---|
| GET | /entries | {entries: Entry[], sequence} (one consistent snapshot) | |
| POST | /entries | {path, kind: "file"|"folder", content?, encoding?, actor?, parents?=true} | File |
| GET | /path?path=a/b.md | File | |
| GET | /files/{id}/raw | ?revision=; Range: bytes=… | raw bytes, streamed, Content-Type from extension (Large files) |
| PUT | /files/{id}/raw | raw body; ?revision=&actor= | Entry (streamed replace) |
| POST | /upload | raw body; ?path=&parents=true&actor= | Entry (streamed create) |
| GET | /files/{id} | File | |
| PUT | /files/{id} | {content, encoding?, revision?, actor?} | File |
| POST | /files/{id}/append | {text, actor?} | Entry |
| PATCH | /files/{id} | {revision, path, actor?} (rename/move; rewrites links, see below) | File |
| DELETE | /files/{id} | {revision, actor?} | {deleted} |
| GET | /files/{id}/history | {versions: Version[], retention_days: number|null} (newest first; see History retention) | |
| GET | /files/{id}/links | {outgoing: Link[], backlinks: Backlink[]} | |
| GET | /search?q=&limit= | SearchHit[] | |
| GET | /changes?after=N&wait=true | long-poll (≤ 25 s; ends with 401 if access is revoked) | Event[]; 410 {code: "cursor_expired", floor} when N is older than the kept history |
| GET | /locks?file=<id or path>&actor= | file optional (path is an alias); actor: local-key label for mine | Lock[] (active only) |
| POST | /locks | {path, range?: {start_line, end_line?} | {heading}, ttl_seconds?=600, reason?, actor?} | Lock (423 if it overlaps someone else's) |
| POST | /locks/{id}/renew | {ttl_seconds?, actor?} (holder only) | Lock |
| DELETE | /locks/{id}?actor=&force= | holder, or an owner with force=true | {released, lock} |
| GET | /access?path= | Access: the caller's own level at a path (see Folder and file permissions) | |
| GET | /grants?path= | manage access at path | ShareListing |
| PUT | /grants | {path, level, principal: {kind, id}} or {path, level, email} | ShareListing |
| DELETE | /grants?path=&kind=&id= | ShareListing | |
| GET | /timeline?actor=&path=&session=&kind=&since=&until=&limit=&cursor= | {entries: TimelineEntry[], next_cursor} (see Timeline) | |
| GET | /history?after=&since=&path=&limit= | HistoryPage: every change across files in sequence order (see History export) | |
| GET | /history/blob?id=&revision=&path= | raw bytes of a file as of revision | |
| POST | /timeline | {kind?, name, path?, args?, result?, error?, duration_ms?, revision?, session?} | TimelineEntry (also for read-only credentials) |
Every route above applies the caller's folder and file permissions:
entries the caller cannot see are left out (listing, search, links, feed, locks) or answer
404 file not found (by id or path), and changes where the caller may not write answer 403.
Write semantics — there is no "save" or "commit":
PUTwithoutrevision: replaces the latest content, applied as a minimal edit to the shared CRDT so people typing elsewhere in the document are not disturbed.PUTwith a stalerevision: three-way merged against everything since (line level, then character level). Only edits to the very same characters produce409 revision conflict. Revisions from the middle of a live editing session are not kept on the server, so also sendbase: the content you read at that revision (sameencodingascontent). Without it, such a write is a409asking forbase. The SDK,flockfs syncand the MCP tools send it automatically.appendis atomic; concurrent appends never clobber each other (good for logs / JSONL-style feeds).- Every mutation is one PostgreSQL transaction under a workspace lock: file row, event and version commit together or not at all, and event sequences are committed in order.
Rename/move rewrites links: when a file or folder moves, every Markdown/text file (.md,
.markdown, .txt) whose label or [[wikilink]] pointed at a moved entry — and no
longer would — is re-pointed in the same transaction. The link keeps its style (wiki basename vs.
path, with/without .md, #fragment, |alias, relative vs. /root href, <angle> href, title).
Links inside moved files that were relative to their old location are fixed too. Each rewritten file
gets one updated event, attributed to the mover, carrying a minimal CRDT update that live editors
apply like any other edit. The PATCH emits the moved events first, then these updated events.
Files the mover may not write are never edited: their links stay as they were, and the PATCH
response adds links_not_rewritten: {count, paths} (paths lists only the ones the mover can read).
History: every change is recorded. history folds bursts of edits by one actor (gap ≤ 90 s) into one
version, and a live editing session (browser edits, gap ≤ 90 s) into one version even when several
people typed in it: actor is whoever edited last and actors lists everyone, in order of first edit.
Version: {revision, first_revision, actor, actor_name, actors, kind, created_at, updated_at, changes, encoding, content, diff}
where diff is a unified diff from the previous version (actor_name: see Display names). Restore = PUT the old content.
Large files
Any file can be sent and read as a raw, streamed body; files over 25 MiB only that way.
POST /api/upload?path=a/b.mp4[&parents=true][&actor=]creates a file from the request body (anyContent-Type;409if the path exists).PUT /api/files/{id}/raw[?revision=N][&actor=]replaces a body. Both return theEntry(no content). A body of live text (UTF-8, ≤ 2 MiB) becomes live text and, with a stalerevision, is three-way merged likePUT /files/{id}; anything else is a whole-file body and a stalerevisionis409. Over the server's limit:413 {code: "file_too_large", limit}; over a plan's byte cap:413 quota_exceededas usual.GET /api/files/{id}/raw[?revision=N]streams the body (?revision: a kept version). OneRange: bytes=a-b/a-/-ngives206withContent-Range; an unsatisfiable one416. Headers:Content-Length,Accept-Ranges: bytes,X-Flockfs-Revision, and for bodies in the blob storeETag: "sha256-<hex>".- JSON reads of a body over 25 MiB (
GET /files/{id},/path) answer413 {code: "use_raw", size}. In/files/{id}/history, a version over 25 MiB hascontent: ""andomitted: <size>; read it withGET /files/{id}/raw?revision=<revision>. - Exports (
/export) stream; entries or archives past 4 GiB use ZIP64.
History retention
A drive keeps history for its plan's history_days (see Plans and limits:
free 30 days, team 365, enterprise and self-hosted forever). GET /files/{id}/history answers
{versions, retention_days} (retention_days: null: forever), so a UI can say "History kept for
30 days". Older versions and events are pruned in the background (hourly per awake drive,
FLOCKFS_RETENTION_INTERVAL_SECS; on wake for hibernated ones), in bounded batches; the agent
Timeline follows the same window. Pruning never touches:
- any file's current content and revision (reads,
revisionchecks and the newest version); - per file, the newest version at or before the window start (the content in effect when the window
begins), so the oldest version shown still has a correct diff and a writer holding that revision
still merges; a writer holding an older, pruned revision merges with
base(the content it read, asflockfs sync, mount and the SDK send) or gets a409 revision conflict … send base— never a silent overwrite; - live edits not yet compacted into the stored content, and edits of a session still in progress;
- every event after the drive's history floor, deletions included.
The history floor is a sequence: every event after it is kept. A change-feed cursor at or above
it is always valid (the sequences from /entries and live ready never fall below it). A cursor
below it has expired:
GET /changes?after=N→410 {"error": "...", "code": "cursor_expired", "floor": F}./livewithafterbelow the floor →{type: "resync", floor, sequence}beforeready; the socket then delivers every change aftersequence.
Either way the client re-lists (GET /entries), re-reads what it derived from events, and
continues from the listing's sequence. The bundled clients do: flockfs sync rescans the whole
folder against its last-synced copies (nothing is treated as deleted, local edits are kept), mounts
re-list their tree, the web app re-lists (its offline queue is unaffected), the SDK's watch calls
onResync (relist: true re-lists for you; an expired explicit after without either throws
CursorExpiredError), flockfs watch continues after the floor with a note on stderr, and the MCP
changes tool tells the agent to re-list and gives it a fresh cursor.
Types:
Entry = {id, path, kind: 'file'|'folder', revision, size, text,
access: 'none'|'read'|'write'|'manage'} // the caller's level here; 'none' = a folder listed
// only because something inside is shared with the caller
File = Entry & {encoding: 'utf8'|'base64', content: string, state: string|null}
Event = {sequence, id, path, previous_path?, kind: 'created'|'updated'|'moved'|'deleted', revision, actor, actor_name, update: string|null}
// actor is the stable id (group/compare by it); actor_name is for display (see Display names)
// previous_path is present on 'moved' events (also for each entry inside a moved folder),
// so a client filtering by folder sees entries moving in and out (SDK watch({prefix}), flockfs watch --prefix)
Link = {href, label, wiki, status: 'resolved'|'missing'|'ambiguous'|'external'|'unsafe', target: Entry|null}
Backlink= {source: Entry, label, context}
SearchHit = {id, path, matches: {line, text}[]}
Lock = {id, file_id, path, actor, actor_name, reason, created_at, expires_at, expires_in, renewable: true,
mine?: boolean, // REST only: the caller holds it
range: null // null = the whole file
| {start_line, end_line, // 1-based, inclusive, as of this read
start, end, // character offsets, end exclusive
anchor_start, anchor_end}} // base64 Yjs relative positions (Y.decodeRelativePosition)History export
GET /history pages through the drive's whole history, across files, oldest first, for tools
that export or inspect retained versions. Read-only; any reader.
after(default 0): the cursor; pass backnextwhilemoreis true.limit1–2000 (default 500) events examined per page.since(a duration like7d, or an ISO 8601 time): start the window later than the floor.path: only that folder (or file) and what is inside it.- When
afteris before the windowstart(the history floor, orsince), the answer addsbase: what existed atstart(eventscontinue after it).
HistoryPage = {floor, head, start, base?: {sequence, time, entries: {id, path, kind}[]},
events: HistoryEvent[], next, more}
HistoryEvent = {sequence, id, path, previous_path?, kind: 'created'|'updated'|'moved'|'deleted',
entry_kind: 'file'|'folder', actor, actor_name, actor_email?, session?,
at /* ISO 8601 UTC */, time /* unix seconds */, live}Permissions apply per path, as in /changes: changes at unreadable paths are left out, and a move
between an unreadable and a readable place is reported as created (moved in) or deleted
(moved out) at the readable path, without previous_path. actor_email is a member's sign-in
email (user: actors; not shown to agent tokens). session is the run id the
timeline recorded for that revision (X-Flockfs-Session), when there is one.
GET /history/blob?id=&revision= answers the file's bytes as of revision (its newest kept
version at or before it, or its current content if unchanged since); 404 when it did not
exist then, that history was pruned, or its path then is not readable (or outside path).
Live edits are kept as versions when their session ends, so a revision in the middle of a live
session answers the content as of the last kept version before it.
Use flockfs export --history for a portable zip of current files and their retained versions,
or flockfs export --dir DIR for ordinary files in a new folder.
Drives
A server holds one or more orgs; an org holds drives; a drive holds folders and files.
Everything else in this document describes one drive (members, roles, tokens, invites, grants,
locks, history, sequences and /changes are all per drive). Each drive is its own PostgreSQL schema
(flockfs_d_<id>; the control schema is FLOCKFS_CONTROL_SCHEMA, default flockfs). On first start after
upgrade the existing workspace is adopted in place as the drive main (legacy: true, still in its
old schema): ids, revisions, sequences, tokens, ?file= links and sync checkpoints stay valid.
Drive reference {drive} in a URL is one of:
| Form | Example | Resolves |
|---|---|---|
| id | k3vzq2m7xa4b | 12 chars [a-z2-7], permanent; tried first |
slug-id | research-k3vzq2m7xa4b | the id after the last - (the slug part is ignored; canonical web form) |
| slug | research | [a-z0-9-]{1,40}, unique per org among live drives; looked up in the caller's orgs, then among the org's former slugs (renames keep old slugs as aliases until another drive takes them) |
Reserved slugs: api, new, settings, d. A slug matching drives in two of the caller's orgs is
409 {error, code: "drive_ambiguous", drives: [{id, slug, org: {id, slug, name}}]} (use the id).
Unknown, soft-deleted and forbidden drives all answer the identical
404 {"error": "drive not found", "code": "drive_not_found"}.
Routing. Every /api/<rest> route of this document is also served at
/api/drives/{drive}/<rest> (e.g. GET /api/drives/research/entries,
GET /api/drives/research-k3vzq2m7xa4b/files/{id}/raw). Unscoped /api/<rest> keeps working: it
goes to the agent token's drive; for human accounts it prefers their personal drive, then another
drive they can open. The local key uses the server's default drive.
Responses never mix drives; file ids from one drive are 404 in another.
Agent tokens belong to exactly one drive: flockfs_<driveid>_<64 hex>. Tokens created before
drives exist (flockfs_<64 hex>) belong to the default drive and keep working. The prefix only routes:
a token presented to another drive (forged prefix included) fails its hash lookup there → 401.
The local key (data/token) is an org owner. Supabase identities are global; access is per drive
(membership in that drive, or org owner, which is owner of every drive of the org).
| Method | Path | Who | Body / query | Returns |
|---|---|---|---|---|
| GET | /api/drives | any | ?deleted=1 adds soft-deleted drives the caller can restore | {drives: Drive[], default: string|null} — drives the caller can open (agent token: only its own); default = the id unscoped /api/* uses |
| POST | /api/onboard | verified human account | none | {drive: string|null} — idempotent personal free workspace; configured legacy owner keeps its existing drive. New provisionings require available FLOCKFS_SIGNUP_MAX_WORKSPACES capacity; closed/full returns 503 signup_capacity. |
| POST | /api/drives | org owner | {name, slug?, org?} (slug derived from name if omitted; org id/slug if the caller owns several) | 201 Drive |
| PATCH | /api/drives/{drive} | drive owner | {name?, slug?} | Drive (old slug kept as an alias) |
| DELETE | /api/drives/{drive} | drive owner | {ok, deleted_at, purge_after} — soft delete: routes 404 at once, live sockets and long polls end, data purged after 30 days | |
| POST | /api/drives/{drive}/restore | drive owner | {slug?} ({drive} = id; needed if the slug was taken meanwhile) | Drive |
| GET | /api/drives/{drive}/export | any reader | ?history=1 | application/zip (see below) |
Org = {id, slug, name, plan: Plan} // Plan: see "Plans and limits"
Drive = {id, slug, name, legacy: boolean, // legacy: the adopted pre-drives workspace
org: Org, default: boolean, // the org's default drive
role: 'owner'|'editor'|'viewer', // the caller's role here
role_source: 'member'|'org_owner'|'token', // token: agent tokens
created_at, deleted_at: string|null, purge_after: string|null}Errors: 400 {code: "invalid_slug"} (bad or reserved), 409 {code: "slug_taken"},
409 {code: "drive_ambiguous"}, 409 {code: "drive_legacy"} (the legacy drive and the default
drive can be renamed and emptied but not deleted), 413 {code: "drive_limit"} (drive count limit of
the org), 404 {code: "drive_not_found"}, 403 for a non-owner managing a drive they can see.
Export. A zip of every file the caller can read in that drive (same permissions as
GET /entries), at their paths, plus .flockfs/manifest.json:
{format: "flockfs-export", version: 1, drive: {id, slug, name, org}, exported_at, sequence, entries: [{id, path, kind, revision, size, text}]}. ?history=1 adds every stored version as
.flockfs/history/<file id>/<revision> (raw bytes) and history: {<file id>: [{revision, actor, kind, created_at}]} in the manifest. Admins can also pg_dump -n flockfs_d_<id>.
Limits. Every org has a plan with drive, file, size, collaborator and history limits; see Plans and limits. Self-hosted servers are unlimited unless configured.
Live, /changes, MCP. /live serves one drive per socket: the first message may carry
drive (or use /live?drive=<ref>); otherwise the token's drive, else the default. An unknown or
forbidden drive answers {type: "error", error: "drive not found", code: "drive_not_found"} and
closes. Deleting a drive sends {type: "error", error: "drive deleted", code: "drive_closed"} and
closes. Hosted MCP is /mcp/{drive}; bare /mcp is the token's drive, else the default. Sequences
and watch({after}) cursors are per drive.
OAuth. resource is <origin>/mcp (token's/chosen drive) or <origin>/mcp/{drive}. The
authorization code and refresh token remember the resolved drive id; a later resource that
resolves to a different drive is invalid_target. Protected-resource metadata is also served at
/.well-known/oauth-protected-resource/mcp/{drive} (its resource is that URL). Consent:
GET|POST /api/drives/{drive}/oauth/consent (drive owner or org owner of that drive); the code is
for the drive in the URL. For a bare /mcp resource the consent page offers a drive picker (drives
from GET /api/drives where the person is owner) and posts to the chosen drive's endpoint; a
resource naming a drive must be approved on that drive's endpoint (elsewhere 403). The GET answer
adds drive (the endpoint's drive: {id, slug, name, legacy, org}) and resource_drive (the id the
resource names, or null for bare /mcp); can_approve is false when they differ. The issued agent
token is a token of that drive (flockfs_<driveid>_…). The oauth_* tables live in the control schema.
Org roles. flockfs.org_members holds owner / member / guest per org. Owners are owners of
every drive of the org (their drive membership, if any, does not cap them); adding someone to a drive
(member or accepted invite) makes them at least a guest. Managed through any drive of the org:
| Method | Path | Who | Body | Returns |
|---|---|---|---|---|
| GET | /api/org/members | drive owner | [{id, actor, role: 'owner'|'member'|'guest', email, email_verified, name, display}] (owners first) | |
| PUT | /api/org/members/{userId} | org owner / local key | {role: 'owner'|'member'|'guest'} | {ok} (403 for others; 400 demoting the last org owner) |
| DELETE | /api/org/members/{userId} | org owner / local key | {ok} (removes the org role; drive memberships stay) |
A change applies at once on every drive: open sockets and long polls of the org recheck their access
(a promoted viewer's socket closes so the client reconnects as owner). Servers without drives answer
404.
Web. /d/<slug>-<id> (and /d/<ref>/…) serve the web app; /d/<slug>-<id>?file=<fileId>
opens a file. Legacy /?file=<id> opens it in the default drive.
Single-drive servers. Sandboxes (--sandboxes, one drive per sandbox) and embedded/test
servers have no control plane but answer the same way: GET /api/drives lists the one drive,
/api/drives/{its id, slug or "main"}/<rest> equals /api/<rest>, other refs are 404 drive_not_found. Clients can therefore always use the scoped form.
Plans and limits
Every org has a plan (flockfs.orgs.plan). The plan sets the org's limits; null always means
unlimited.
| Plan | Drives per org | Files per drive | Storage (bytes) | Collaborators per org | History |
|---|---|---|---|---|---|
free | 1 | 1,000 | 100 MiB (104,857,600) per drive | 3 (the owner included) | 30 days, 256 MiB retained budget |
team | unlimited | 100,000 | 20 GiB (21,474,836,480) per person, pooled across the org | unlimited | up to 365 days, 5 GiB per paid seat pooled across the org |
enterprise | unlimited | unlimited | unlimited | unlimited | forever |
self-hosted | unlimited | unlimited | unlimited | unlimited | forever |
team is the paid plan. Its monthly/yearly per-person prices come from the configured Stripe catalog.
Its interim names yumi and flockfs are still accepted wherever a plan is named (FLOCKFS_PLAN,
PUT /api/org/plan, flockfs org plan), stored values are migrated to team at startup, and the API
always reports team. Agent connections are included without additional paid seats; all connections share the workspace's storage, retained-history and traffic limits.
- Files are files plus folders in one drive; bytes are current content (history is not
counted). On
freethe byte limit is per drive (with one drive, the same thing as per org). Onflockfsit is one pool for the whole org: the content of all its live drives together may use 20 GiB × purchased seats for a Stripe-billed org. For manually assigned Team orgs without a subscription, it uses max(1, collaborators), counted as below. Reducing the pool never deletes current files; it refuses growth until usage is back under the limit. - Collaborators are distinct people with access anywhere in the org, each counted once: active human members of any live drive of the org, pending email invites of any live drive (by address; an invite for an address that a counted person already has is not counted again), and org owners. The local key and agent tokens never count; agent tokens do not consume paid seats, but their activity shares workspace limits.
- History is how many days of file versions (and
/changesevents) are kept; older ones are pruned (see History retention). A separate retained-payload budget also applies: Free has 256 MiB per drive; Team has 5 GiB per paid seat pooled across the org.nullkeeps everything.
Choosing the plan. A new org gets FLOCKFS_PLAN (free, flockfs, enterprise or self-hosted;
unset: self-hosted, i.e. unlimited — so self-hosted servers behave as before). Existing orgs keep
their stored plan; the server operator changes it with PUT /api/org/plan or flockfs org plan PLAN.
Server-wide variables override the plan of every org (a number, or unlimited):
FLOCKFS_MAX_DRIVES, FLOCKFS_DRIVE_MAX_FILES, FLOCKFS_DRIVE_MAX_BYTES (always a per-drive cap,
in addition to any org pool), FLOCKFS_ORG_MAX_BYTES (a fixed org pool), FLOCKFS_BYTES_PER_PERSON (a
per-person org pool; FLOCKFS_ORG_MAX_BYTES wins when both are set; unlimited on either removes the
pool), FLOCKFS_MAX_COLLABORATORS, FLOCKFS_HISTORY_DAYS. Per-drive overrides in the control table (drives.limits
{"max_files": n, "max_bytes": n}) win over both for that drive. A plan change applies to running
drives at once on the server that made it, and within a minute on other server processes.
Downgrades never lock anyone out. An org over a limit (e.g. after flockfs → free, or after people leave a flockfs org) keeps every
member, invite, drive and file; the limit only refuses adding more (another person, another drive,
more files or bytes). Deleting, shrinking and editing within the limits keep working.
| Method | Path | Who | Body | Returns |
|---|---|---|---|---|
| GET | /api/usage (/api/drives/{drive}/usage) | anyone who can open the drive (members of any role, agent tokens, the local key) | Usage | |
| PUT | /api/org/plan (/api/drives/{drive}/org/plan) | server operator only: the local key (data/token) or FLOCKFS_ADMIN_TOKEN — not org owners | {plan: Plan, org?: string} (org: id or slug; default: the org of the drive in the URL, else of the default drive) | {ok: true, org: Org} |
Plan = 'free'|'team'|'flockfs'|'enterprise'|'self-hosted'
Usage = {plan: Plan, // the org's plan (same as Drive.org.plan)
scope: 'org'|'drive', // where limits.bytes / usage.bytes apply
history_scope?: 'org'|'drive', // where retained-history limits / usage apply (older servers: drive)
traffic_scope?: 'org', // traffic capacity is shared across drives and credentials
traffic?: TrafficLimits|null, // technical caps; absent on older servers or when not enforced
limits: {drives: number|null, // live drives per org
files: number|null, // files + folders in this drive
bytes: number|null, // content bytes: the org pool (scope "org") or this drive's cap
drive_bytes: number|null, // this drive's own byte cap, whatever the scope
bytes_per_person: number|null, // per-person pool (bytes = this × max(1, collaborators))
collaborators: number|null,// people per org
history_days: number|null, history_bytes?: number|null},// null: unlimited / kept forever
usage: {drives: number, // live drives of the org
files: number, // in this drive (all of it, whatever the caller may see)
bytes: number, // all live drives of the org (scope "org"), else this drive
drive_bytes: number, // in this drive
collaborators: number, // people in the org, as defined above
history_bytes?: number}} // accounted retained payload in history_scopeTrafficLimits has integer fields requests_per_minute, request_burst,
live_frames_per_minute, live_frame_burst, write_bytes_per_minute,
write_byte_burst, read_bytes_per_minute, read_byte_burst,
concurrent_requests, and live_connections. These are capacity controls, not
metered overage charges. All drives, people and agent credentials of the org share
the same budget on one server process. A deployment with multiple server processes
has a separate budget on each process; these are not distributed global counters.
A traffic refusal returns HTTP 429 with Retry-After and
{code: "rate_limited", scope: "org", resource, retry_after} (seconds); live sockets
receive an error with the same fields. Retrying later does not change the price.
limits are the effective ones for this drive (plan, then server-wide variables, then the drive's own
overrides). Example for a fresh free org:
{"plan":"free","scope":"drive","limits":{"drives":1,"files":1000,"bytes":104857600,"drive_bytes":104857600,"bytes_per_person":null,"collaborators":3,"history_days":30,"history_bytes":268435456},"usage":{"drives":1,"files":0,"bytes":0,"drive_bytes":0,"collaborators":1,"history_bytes":0}}.
A team org with two people: scope: "org", limits.bytes: 42949672960 (2 × 20 GiB),
limits.bytes_per_person: 21474836480, limits.drive_bytes: null, and usage.bytes summing every
live drive of the org. With two purchased seats, history_scope: "org",
limits.history_bytes: 10737418240 (2 × 5 GiB), and usage.history_bytes summing retained
payload across the org.
Drive.org.plan (GET /api/drives, GET /api/drives/{drive}, /api/me drive.org.plan) carries the
same plan. Single-drive servers (sandboxes, tests) answer /api/usage with plan self-hosted, the
sandbox caps as files/bytes, drives: 1 and this workspace's people; PUT /api/org/plan there is
405 {code: "single_drive"}.
FLOCKFS_ADMIN_TOKEN (at least 24 characters) exists because Supabase-mode servers disable the local key;
it is accepted only by PUT /api/org/plan (it opens no drive). The CLI sends its usual credential
(--token, FLOCKFS_TOKEN, else data/token): flockfs org plan prints the plan, limits and usage of the
drive's org (--drive), flockfs org plan team sets it. Optional Stripe billing uses the same stored
org plan and refreshes awake drives after verified subscription changes.
Stripe billing
Optional and independent of Supabase. See configuration.
The read, checkout and portal routes also accept the /api/drives/{drive} prefix to select the drive's org.
All responses use Cache-Control: no-store.
| Method | Path | Access | Body | Response |
|---|---|---|---|---|
| GET | /api/billing | authenticated drive access | — | Billing |
| GET | /api/pricing | public, global route | — | {enabled, currency: "usd", monthly: BillingPrice | null, yearly: BillingPrice | null} |
| POST | /api/billing/checkout | org owner or local workspace key | {interval: "month" | "year", seats: integer, price_id: string} | {url} (Stripe Checkout) |
| POST | /api/billing/portal | org owner or local workspace key | {} | {url} (Stripe customer portal) |
| POST | /api/billing/webhook | valid Stripe-Signature; global route only | raw Stripe event body | {received: true} |
Billing contains enabled, can_manage, org: Org, min_seats, seats: number | null,
interval: "month" | "year" | null, status: string | null, has_subscription,
monthly_available and yearly_available. has_subscription means a subscription needs
management; it does not confirm payment or paid access. The org's server-confirmed plan is
the entitlement. Missing configuration and manually managed enterprise/self-hosted orgs report
enabled: false; old servers may return 404.
Checkout validates the configured USD recurring prices, mode and seat count (1–10,000 and no
less than the org's current people/pending invites). BillingPrice is {price_id, unit_amount, interval} with positive integer USD cents. The public catalog is cached server-side for five
minutes; checkout validates fresh Stripe data. Missing or stale displayed price_id returns
409 {code: "price_changed"}: refresh pricing and ask the person to click again, never automatically
retry checkout. Retries reuse an open checkout for the same quantity/period/price. A completed
checkout awaiting reconciliation cannot create a second subscription.
Existing subscriptions use the portal, including subscriptions with an outstanding payment.
Agents, editors and owners of only a drive cannot manage billing. The server operator's
FLOCKFS_ADMIN_TOKEN does not grant checkout or portal access.
Webhook signatures use the unchanged raw body and a five-minute timestamp tolerance. Duplicate
events are recorded durably; each new event retrieves the current subscription from Stripe so
an old event cannot roll the plan back. Subscription/customer/checkout associations, configured
price, mode and paid invoice are verified before granting Team. Paid zero-dollar invoices from
credits are valid. Purchased seats cap new people/invites and determine the org storage pool;
reducing seats does not remove existing members. Non-paid subscriptions return to Free with the
usual growth and history-retention limits. Checkout return URLs do not grant a plan.
With FLOCKFS_STRIPE_PRODUCT_ID set, paid subscriptions on older, including archived, prices of
the approved product retain their entitlement when new signup prices are selected. Billing
amounts change only through explicit Stripe subscription management, not by changing the catalog.
Plan/usage errors (all JSON {error, code, …}):
| When | Status | Body |
|---|---|---|
Creating a drive beyond drives | 413 | {code: "drive_limit", limit, used} |
A write beyond files or bytes: create (also uploads and the parent folders parents: true adds), write (also restoring a version), append, live CRDT update (may overshoot by ≤ 4 KiB), and therefore MCP tools, flockfs sync, flockfs mount and the CLI | 413 | {code: "quota_exceeded", resource: "files"|"bytes", limit, used, scope: "drive"|"org"} (scope: "org": the org pool, limit its current size and used the org's bytes before the write; scope: "drive": this drive's cap and usage); on /live the reply is {type: "error", id, code: "quota_exceeded", error, resource, limit, used, scope}. Shrinking writes, deletes and moves add no current-file storage, but remain subject to the separate retained history budget. Concurrent writes to different drives of one org are checked independently, so the pool can be overshot by what they add at the same moment |
Adding a person beyond collaborators: POST /api/members ({email} or {id}), PUT /api/members/{id} for someone without access yet, PUT /api/grants with an email that creates an invite, PUT /api/org/members/{id} making someone new an org owner, and invite activation if it would raise the count (an already pending invite counts, so its activation always succeeds) | 403 | {code: "collaborator_limit", limit, used} (used: people counted now) |
PUT /api/org/plan by anyone but the operator | 403 | {code: "forbidden"} (401 without a credential) |
PUT /api/org/plan with another plan name | 400 | {code: "invalid_plan"} |
PUT /api/org/plan naming an unknown org | 404 | {code: "org_not_found"} |
Sandboxes keep their own caps and code: "sandbox_limit" (413).
Server-side hook. App::history_days() / Quota::history_days() (src/drives.rs) return the
drive's effective history window in days (None: forever); history retention uses it, and
Hub::history_days_for(&DriveInfo) answers it for drives that are not awake.
Timeline
"What did this agent do?" Every drive keeps a queryable timeline (audit trail) of agent actions:
- MCP tool calls are recorded automatically, over stdio (
flockfs mcp) and hosted MCP (/mcp), with tool name, main path, sanitized arguments,ok/error(and the error), duration and, forread/write/edit/append, the resulting revision. The MCP server records each call withPOST /timelineusing the caller's own credential, so the actor is the one the server verified. - Anyone else logs their own actions:
POST /timeline(SDKlogAction) from scripts, agents using the SDK or CLI,flockfs runwrappers. Read-only credentials may log too (it changes no file).
The actor always comes from authentication, never from the body: agent:<token id> (agent tokens),
user:<id> (signed-in people), local (the shared key). actor_name is the token label or the
person's name/email when recorded; for the shared local key it is the advisory client label (body
actor, or the MCP --actor, default mcp-agent), as for edits.
Sessions. A run groups its entries by a client-chosen id: session in the body, or the
X-Flockfs-Session header (also accepted on /mcp). flockfs mcp uses FLOCKFS_SESSION from its
environment. At most 128 characters.
Arguments are sanitized server-side (and by the MCP server before sending): file contents
(content, content_base64, text, old_string, new_string, base, data, body, update)
become {length, sha256, preview, truncated} with the first 200 characters as preview; any other
string over 1000 bytes is summarized the same way; credentials (token, password, secret,
api_key, authorization, …, and any flockfs_…/Bearer … value) become "[redacted]"; nesting
is cut at 6 levels and 50 items, and arguments over 16 KiB become {truncated: true, size}.
TimelineEntry = {id, at /* ISO 8601 UTC */, actor, actor_name, kind: 'tool_call'|'file_op'|'note',
name /* e.g. 'edit', 'write', 'bash' */, path: string|null, args: object,
result: 'ok'|'error', error: string|null, duration_ms: number|null,
revision: number|null, session: string|null}POST /timeline body: name (required, ≤ 64 characters), kind (default tool_call), path
(a relative path), args, result (default error when error is given, else ok), error
(≤ 2000 characters kept), duration_ms, revision, session.
GET /timeline answers newest first, limit 1–200 (default 50); pass next_cursor back as
cursor for older entries (null: no more). Filters, combined with AND:
| Query | Matches |
|---|---|
actor | agent:<id>, user:<id>, local, a bare token/user id, or a display name (case-insensitive) |
path | a file, or a folder and everything in it (case-insensitive) |
session, kind | exactly |
since, until | an ISO 8601 time, or a duration ago: 30s, 15m, 2h, 7d, 1w |
Permissions. The timeline is filtered like everything else: an entry is shown only if the caller
can read every path it mentions (path, and path arguments such as a move's to), so hidden paths
never leak, not even through filters. Entries without a path are shown to callers with read access
to the drive root (members, unscoped tokens); a folder-scoped token sees only its own. A page may
hold fewer than limit entries when many are hidden; keep following next_cursor.
Retention. Entries follow the drive's history retention. Store::prune_timeline(before: SystemTime) -> Result<u64> (src/store/timeline.rs) deletes entries recorded before before;
is the hook for history retention (src/store/retention.rs) to call with the same cutoff as file history.
CLI: flockfs timeline [--agent NAME|ID] [--path P] [--session S] [--kind K] [--since 1h] [--until T] [--limit N] [--json] [--follow] prints time, actor, tool, path, ok/error and duration (oldest of the
page first, like a log; --json prints JSON lines; --follow keeps printing new entries).
Locks
A lock is a lease an actor (typically an agent about to do a multi-step change) holds on a whole
file or a range of a live text file. It expires after ttl_seconds (default 600, clamped to 1–3600);
renew it before then, release it when done. Expired locks are ignored everywhere and purged lazily.
Taking or renewing a lock needs write access; holders are identified like every other mutation
(actor from the body for the local key, the server identity for members and agent tokens).
- Ranges are given as lines (
{start_line, end_line}) or a Markdown heading ({heading: "## Plan"}or"plan": the heading line through the line before the next heading of the same or a higher level). They are stored as Yjs relative positions on thecontenttext: the start sticks to the first locked character and the end to the last, so the span moves as others edit around it.start_line/end_line/start/endare recomputed on every read. Insertions exactly at either boundary are outside the lock (including the holder's own: text added right after a locked section is not locked). Binary files can only be locked whole. - Conflicts. A lock overlapping another actor's active lock is refused with
423. An actor's own locks may overlap. - Enforcement. Any change by a different actor that deletes or replaces a locked character,
or inserts strictly inside a locked span, is refused; a whole-file lock refuses every change.
REST
PUT/append/PATCH/DELETEanswer423 {error, lock}(moves and deletes are refused while anyone else holds any lock on the file). The server checks the CRDT update itself: it applies the incoming update to a scratch copy and inspects the resulting text delta, so concurrent edits are judged by what they actually change. When a file has other actors' locks, a whole-filePUTis applied as line-level edits (not one prefix/suffix replacement) so edits around a lock pass. Link rewriting after a move skips files where it would touch someone else's lock. - Release.
DELETE /locks/{id}by the holder, or by a workspace owner with?force=true(agent tokens are never owners). Renewal is holder-only.
Live clients receive {type:"locks", locks: Lock[]} (every active lock in the workspace) after
ready, whenever a lock is taken, renewed or released, and when one expires. Lock changes are not
events in /changes; poll GET /locks (or the MCP locks tool) instead.
Access
Two modes, chosen by the server's environment. GET /auth/config (no auth) tells the browser which:
| Mode | /auth/config | Who can call |
|---|---|---|
| Local (default) | {"mode":"local"} | the shared workspace key (data/token), plus agent tokens |
| Supabase | {"mode":"supabase","url","publishable_key"} | signed-in members (Supabase JWT, ES256/RS256), plus agent tokens; the shared key is disabled |
Supabase mode is enabled by setting all of FLOCKFS_SUPABASE_URL, FLOCKFS_SUPABASE_PUBLISHABLE_KEY and
FLOCKFS_OWNER_ID (the first owner's Supabase user UUID; seeded once per database — a departed owner is
never re-added on restart). The browser signs in with supabase-js using url + publishable_key
and sends the session's access_token as the bearer credential (REST) and as token (WebSocket).
The server verifies signature (JWKS), issuer, audience authenticated, expiry and subject, then
reads the caller's role from PostgreSQL.
Roles: owner (everything, manages access), editor (read/write files), viewer (read only).
Agent tokens are editor (read/write) or viewer (read-only) and can never manage access. The local
shared key is an owner. Writes by viewers get 403 "This workspace access is read-only". A signed-in
user without membership gets 403 {error, user_id, email: string|null, email_verified: boolean} —
show user_id (and email) so they can send it to an owner; email_verified: false means an email
invite cannot activate for this sign-in (see Invites).
Attribution. In local mode with the shared key, actor in bodies (and the WebSocket hello) is an
advisory label. For verified humans and agent tokens it is ignored: the server records
user:<uuid> or agent:<token id>.
Display names
actor stays the stable identity everywhere (user:<uuid>, agent:<token id>, or a local label).
Next to it the server exposes a human-readable name:
| Who | display / actor_name |
|---|---|
| Human (Supabase) | user_metadata.full_name, else user_metadata.name, else email, else user:<uuid> |
| Agent token | the token's label |
| Local shared key | the label the client typed (actor in bodies / the WebSocket hello); /api/me says "local" |
Human details come only from the verified access-token claims (email lowercased; names trimmed,
single-line, ≤ 120 chars). They are saved to PostgreSQL (auth_profiles) whenever a member
authenticates with claims that differ from what is stored, so a renamed account updates on its next
request. Non-members are never recorded. actor_name on events and history is resolved when read,
so it always shows the current name, and a removed member keeps their name in history. Unknown
identities (e.g. a member who has not signed in since this feature, or a deleted token row) fall back
to actor.
Where it appears:
GET /api/me→display,email,name- WebSocket
presencepeers →kind,display,email Event.actor_name(/changes, WebSocketchange) andVersion.actor_name(history)GET /api/members→email,email_verified,name,display,actorGET /api/tokens→display,actor,created_by_name;GET /api/invites→invited_by_name
| Method | Path | Who | Body | Returns |
|---|---|---|---|---|
| GET | /api/me | any | Me | |
| GET | /api/members | owner | Member[] (active members, then org owners who are not members; role is the effective role) | |
| POST | /api/members | owner | {email, role} or {id, role} (exactly one of email/id) | {ok, status: 'pending', invite: Invite} or {ok, status: 'active', member: Member} |
| PUT | /api/members/{userId} | owner | {role: 'owner'|'editor'|'viewer'} | {ok} (400 if it would demote the last owner — unless the org has an owner) |
| DELETE | /api/members/{userId} | owner | {ok} (400 for the last owner) | |
| GET | /api/invites | owner | Invite[] (pending only, newest first) | |
| PUT | /api/invites/{email} | owner | {role} | {ok} (404 if no pending invite) |
| DELETE | /api/invites/{email} | owner | {ok} (404 if no pending invite) | |
| GET | /api/tokens | owner | [{id, actor, label, display, created_by, created_by_name, write, created_at, expires_at, revoked_at, active, scopes}] | |
| POST | /api/tokens | owner | {label, level?: 'view'|'edit', write?=false, days?=30, scopes?: [{path, level: 'none'|'view'|'edit'}]} (level overrides write; 1–90 days, label 1–80 bytes, ≤ 64 scopes) | {id, label, write, level, expires_at, token, scopes} (level as wire name: read or write; GET /api/tokens lists it too; tokens from before levels are read/write by their write flag) |
| DELETE | /api/tokens/{id} | owner | {ok} (idempotent; 404 for an unknown id) | |
| POST | /api/browser-session | local key | {ticket, expires_in: 60} (local mode only) | |
| POST | /auth/browser-session | none | {ticket} | {token} — single-use, 60 s (local mode only) |
Me = {id, actor, role, kind: 'local'|'human'|'agent', label?: string, // label: agents only
display: string, email: string|null, name: string|null, // email/name: humans only
scopes: {owner: boolean, default: Level, grants: [{path, level: Level}]}, // path permissions
role_source: 'member'|'org_owner'|'token', // org_owner: owner via the org (and the local key)
drive: {id, slug, name, legacy: boolean, org: {id, slug, name}}} // the drive answering
Member = {id, actor: 'user:<id>', role, email: string|null, email_verified: boolean,
name: string|null, display: string, // email/name null until the member signs in
source: 'member'|'org_owner', // org owners are listed too (read-only here)
member_role: Role|null} // the drive membership's own role (null: org owner only)
Invite = {email, role, invited_by: '<owner uuid>'|'local', invited_by_name: string,
created_at: string, status: 'pending'}
Token = {id, actor: 'agent:<id>', label, display /* = label */, created_by: '<owner uuid>'|'local',
created_by_name /* owner's display, or 'local' */, write, created_at, expires_at, revoked_at, active}Invites by email
POST /api/members {email, role} records a pending membership for that address (trimmed,
lowercased; 400 for a malformed address or role). Re-inviting a pending address just changes its role.
It is 409 when the address already belongs to a member whose verified email is on file (change that
member's role instead). Nothing is sent — share the workspace URL yourself. No Supabase service-role
key is used: activation relies only on the signed access token.
Activation happens on the invitee's first authenticated request (REST or WebSocket hello): when a
verified token's email equals a pending invite and the email counts as verified — email_verified
(top level) and user_metadata.email_verified are each either absent or true, and is_anonymous
is not true — the server, in one transaction, deletes the invite and adds the account with the
invited role; the request then proceeds normally (e.g. /api/me returns the new role). Activated
invites disappear from GET /api/invites and the account appears in GET /api/members. An invite for
an address that already belongs to a member is discarded when that member signs in. Revoke with
DELETE /api/invites/{email}, change with PUT /api/invites/{email} (URL-encode the address; matching
is case-insensitive). Adding by account id (POST {id, role} or PUT /api/members/{id}) still works.
Invites are stored in local mode too, but only Supabase sign-ins can activate them.
Security: the invite trusts the provider's
email_verifiedflag.
Agent tokens look like flockfs_ + 64 hex characters. The secret is in the POST /api/tokens response
only; PostgreSQL stores its SHA-256 hash. created_by is the creating owner's user id, or
local. Timestamps are UTC ISO-8601. Access responses carry Cache-Control: no-store.
Revocation is immediate: every request re-authenticates, mutations recheck the role inside the
storage transaction, in-flight long polls end with 401, and live sockets recheck about every 500 ms —
a revoked/expired credential, removed membership or changed role closes the socket (reconnect to
revalidate).
Folder and file permissions
Roles and token flags set a default for the whole workspace; grants refine it per folder
or file. A grant is (principal, path, level):
principal: a member ({kind: "user", id: <account uuid>}), a pending invite ({kind: "invite", id: <email>}— moved to the account when the invite activates), or an agent token ({kind: "agent", id: <token id>}).path: a folder or file, relative,""(or"/") for the whole workspace. A grant on a folder applies to everything inside it. Matching is case-insensitive, per path segment (researchcoversresearch/a.md, notresearchers.md).level:none<read(view) <write(edit) <manage(each includes the ones before; people see them as view → edit → manage;viewandeditare accepted as input everywhere a level is).manage= write plus changing grants at and under that path.
Effective level at a path = the most specific grant of that principal whose path covers it;
without one, the default: role owner → manage, editor → write, viewer → read; agent tokens →
write or read (their write flag). A grant on "" replaces the default. Grants can restrict
(none on private/ for an editor) and elevate (write on team/ for a viewer). Owners and the
local key always have manage everywhere (grants on owners are refused, 400). Agent tokens
never get manage (400); their effective level is capped at write.
Visibility. An entry is listed when the caller can read it, or when it is a folder above something
the caller can read — such a path-only ancestor is listed with access: "none" so the tree makes
sense, and reading it as a folder works (folders have no content), but nothing else about it is shown.
Enforcement (server-side, for every credential, also inside the write gate of each mutation):
| Where | Behaviour |
|---|---|
GET /entries | only visible entries; each with the caller's access |
GET /files/{id}, /raw, /history, /links, /path | unreadable → 404 file not found (indistinguishable from a missing file) |
POST /entries | needs write at the new path (403); missing parent folders are created on the way |
PUT, append, DELETE, WebSocket update | needs write at the file (403; 404 if not visible) |
PATCH (move/rename) | needs write at the old and the new path of every moved entry, including everything inside a moved folder (403) |
| link rewriting after a move | skips files the mover cannot write (links_not_rewritten) |
GET /search | only readable files (paths and lines); limit counts readable hits |
GET /files/{id}/links | outgoing links to unreadable targets are status: "missing", target: null; backlinks from unreadable files are left out (no titles, no context) |
GET /changes, WebSocket change (incl. replay) | only events on visible paths; a move from a hidden into a visible place arrives as created (no previous_path), the reverse as deleted at the old visible path; update is dropped when the caller cannot read the file. Pages of hidden events are skipped server-side, so wait=true keeps waiting and a plain call never returns [] while visible events exist later |
locks (GET /locks, WebSocket locks) | only locks on readable files; taking/renewing needs write |
WebSocket presence | every peer is listed, but id (which file they look at) is null unless the caller can read that file; presence/open/awareness for unreadable files answer error … not found |
MCP (stdio and /mcp) | goes through this API with the caller's token, so all of the above applies to every tool |
| sandboxes | same rules inside each sandbox (the sandbox bot key is an agent token) |
Live changes. Grant changes apply to the next request. Long polls pick up the new grants on their next credential check (≤ 1 s, at once for changes made through this server); a live socket whose role or grants changed is closed — the client reconnects and re-lists (the web app does this on its own). Grants follow a folder or file when it is renamed or moved (same transaction), and stay in place when it is deleted (so a scope may name a folder that does not exist yet). Removing a member or revoking an invite removes their grants.
| Method | Path | Who | Body / query | Returns |
|---|---|---|---|---|
| GET | /api/access?path= | any | Access | |
| GET | /api/grants?path= | manage at path | ShareListing | |
| PUT | /api/grants | manage at path | {path, level, principal: {kind, id}} | ShareListing |
| PUT | /api/grants | manage at path | {path, level, email}: a member with that verified email, a pending invite, or — owners only — a new invite (role viewer, plus none at the root, so they see only what is shared with them) | ShareListing |
| DELETE | /api/grants?path=&kind=&id= | manage at path | ShareListing (404 if there was no grant on exactly that path) |
Level = 'none'|'read'|'write'|'manage' // input also accepts 'view' (= read) and 'edit' (= write)
Access = {path, level, visible, read, write, manage,
source: {type:'owner'} | {type:'default'} | {type:'grant', path, inherited: boolean}}
ShareListing = {path, people: ShareEntry[]}
ShareEntry = {kind: 'local'|'user'|'invite'|'agent', id, display, email?, role, pending?, actor?,
level, // effective level at `path`
source, // as in Access; inherited = from a folder above
direct: {path, level, created_by, created_at} | null} // a grant on exactly `path`people lists the local key (local mode), every member, pending invite and active agent token.
Scoped agent tokens. POST /api/tokens {label, scopes: [{path: "research", level: "write"}]}
creates a token that sees and changes only research/. Paths not covered by a scope get the scope
for "" if one is given (e.g. {path: "", level: "read"} = read the rest), else none. With
scopes, write is derived (true when any scope is write). Scopes are ordinary grants on the token:
GET /api/tokens returns them, and the Share dialog / PUT /api/grants change them later.
OAuth connections can be limited to one folder on the consent page (see below).
Clients. flockfs sync with a scoped credential syncs only what it can see: read-only files are
mirrored and made read-only on disk (chmod a-w); a local edit to one is kept at
.flockfs/conflicts/<id>/denied-<ms>.local and the file is reset to the workspace version; a local
delete of one is undone; new local files where the credential cannot write stay local. All of these
are listed in .flockfs/status.json readonly and never retried in a loop. Files that become invisible
(access narrowed) are not deleted anywhere — they are kept locally and no longer synced. A 403
on a write refreshes the credential's scopes (/api/me) before the next pass. flockfs mount and the
SDK use this same API, so they see exactly the filtered tree.
Browser handoff (local mode): a CLI holding the key mints a ticket and opens
http://host/#ticket=<ticket> (or similar); the page exchanges it with POST /auth/browser-session
for the key, so the key never appears in a URL.
Live update flow control
The browser sends at most one unacknowledged CRDT update per file. Local edits continue in IndexedDB and are coalesced into the next update after acknowledgment. Continuous typing flushes at most every 120 ms when no update is outstanding. A missing ACK after 30 seconds reconnects and resends preserved state; already committed edits are idempotent. Rejected writes remain visible or recovered locally instead of starting an automatic retry loop.
An optional updates: "open" field in the /live authentication message selects
metadata-only change events for files the socket has not opened. Their sequence,
revision, path, actor and session still arrive; update is null. open returns the
complete current document and subscribes to subsequent CRDT payloads; close
stops those payloads. Without this option, clients keep receiving full updates.
This applies to initial replay, live delivery and lagged catch-up. Permission
filtering remains authoritative in both modes. Browsers re-open every loaded
cached document after reconnect, including background documents.
Sandboxes
flockfs serve --sandboxes gives every visitor their own throwaway workspace from one process
(e.g. a public demo). Each sandbox is its own PostgreSQL schema (sb_<id>) and its own workspace;
everything else in this document applies inside it unchanged.
| Flag | Default | |
|---|---|---|
--sandbox-idle-secs | 120 | destroy a sandbox after this long without visitor activity |
--sandbox-max | 500 | live sandboxes; more → 503 |
--sandbox-max-files | 50 | files + folders per sandbox |
--sandbox-max-bytes | 1048576 | total content bytes per sandbox |
--sandbox-seed <dir> | built-in | files copied into each new sandbox (default: AGENTS.md, plan.md, specs/auth.md, agent.log, evals/runs.jsonl, wiki-linked) |
--sandbox-admin-token / FLOCKFS_SANDBOX_ADMIN_TOKEN | unset | bearer token for GET /sandbox/active (unset → 404) |
--sandbox-trust-forwarded-for | off | rate-limit by the first X-Forwarded-For address (only behind a trusted proxy) |
POST /sandbox (no auth; 10 per minute per client address, else 429) →
201 {id, ticket, bot_token, expires_after_idle_secs} and
Set-Cookie: flockfs_sandbox=<id>; Path=/; HttpOnly; SameSite=Lax (Secure when
X-Forwarded-Proto: https). ticket is a single-use, 60 s browser ticket: open /#ticket=<ticket>
and the web app redeems it for the sandbox's visitor key (a local workspace key, owner role).
bot_token is a separate write agent token (flockfs_…, label "Demo collaborators") for demo bots.
At capacity: 503 {error} with Retry-After.
Routing: every other route (/api/*, /live, /mcp, /auth/*, the web app) goes to the sandbox
whose visitor or bot key the request presents (Authorization: Bearer, or the token in the
first /live message), so two tabs holding different sandboxes work in one browser even though
they share one cookie. Otherwise (no key, or a key that is not a sandbox's visitor/bot key, such as
an agent token the visitor minted) it goes to the sandbox named by, in order, the flockfs_sandbox
cookie, the sandbox query parameter (/live?sandbox=<id>, /mcp?sandbox=<id> for clients
without cookies), or the x-flockfs-sandbox header. An unknown or expired id — or none — on a
workspace route is 410 {"error":"sandbox expired"} (a stale cookie is cleared); create a new
sandbox. /live always upgrades (the key is only known from the first message); if no sandbox
matches, the socket gets {type:"error", error:"sandbox expired", code:"sandbox_expired"} and is
closed. Static web files are served without a sandbox. Keys only work in their own sandbox.
Bot identities: requests and sockets authenticated with a sandbox's bot key keep its agent
permissions (editor) but, like the local key, use the client's actor label (advisory): the
/live hello actor, a REST body actor, or x-flockfs-actor (also hosted MCP's ?actor=). The
label is the event actor/actor_name, the lock holder and the presence actor/display; presence
kind is agent for agent-like labels (claude, codex, gpt, agent, bot, mcp, …) and
human otherwise. Labels starting with user: or agent: are ignored (the token's own
agent:<id> is used). So several demo collaborators can share one bot key and still appear as
different people.
Lifetime: a sandbox is active while its visitor is — any request not authenticated with its bot
key (and not rejected with 401), or an open live socket not authenticated with the bot key.
Bot-key requests and bot sockets never keep it alive. After --sandbox-idle-secs with no visitor
activity and no visitor socket, its sockets (bot sockets included) are closed, the workspace is
dropped and DROP SCHEMA … CASCADE runs (checked every ~10 s). A sandbox with no requests or
sockets at all for 20 s releases its PostgreSQL connection and reopens it on the next request.
Leftover sb_* schemas are dropped at startup.
Caps: a write that would exceed the file count, or grow total content past the byte cap, is refused
with 413 {error, code:"sandbox_limit"} (REST and MCP); a live edit is refused with
{type:"error", id, code:"sandbox_limit"} (live edits may overshoot by ≤ 4 KiB). Shrinking writes
and deletes are always allowed. Request bodies are capped at 2 × the byte cap.
GET /sandbox/active with Authorization: Bearer <admin token> →
[{id, bot_token, created_at, last_visitor_activity}] (ISO-8601 UTC, oldest first), so an external
bot runner can attach demo collaborators to each live sandbox (?sandbox=<id> + bot_token) and
detach when it disappears (its sockets close, requests get 410).
OAuth for remote MCP clients
Remote MCP clients that implement the MCP authorization spec
(claude.ai custom connectors, MCP Inspector, …) connect with just the URL https://<host>/mcp.
Direct Authorization: Bearer <key or flockfs_ token> use of /mcp keeps working unchanged.
| Endpoint | Auth | Purpose |
|---|---|---|
POST /mcp without/with a bad token | — | 401 + WWW-Authenticate: Bearer resource_metadata="<origin>/.well-known/oauth-protected-resource" (, error="invalid_token" when a token was sent); for /mcp/{drive} the metadata URL is <origin>/.well-known/oauth-protected-resource/mcp/{drive} |
GET /.well-known/oauth-protected-resource (also …/mcp) | none | RFC 9728: {resource: "<origin>/mcp", authorization_servers: ["<origin>"], scopes_supported: ["read","write"]} |
GET /.well-known/oauth-protected-resource/mcp/{drive} | none | Same with resource: "<origin>/mcp/{drive}"; 404 for an unknown drive (or a server without drives) |
GET /.well-known/oauth-authorization-server | none | RFC 8414 metadata: issuer = origin; code + S256 PKCE only; token_endpoint_auth_methods_supported: ["none"] |
POST /oauth/register | none | RFC 7591 dynamic registration (JSON). Public clients only (token_endpoint_auth_method: "none", no secret). 1–10 redirect_uris, each https://… or http://localhost/127.0.0.1/[::1], no fragment or userinfo → 201 {client_id: "flockfsc_…", …}; otherwise 400 invalid_redirect_uri / invalid_client_metadata. At most 1000 registrations per hour. |
GET /oauth/authorize | none | Validates the request, then 303 to the consent page /oauth/consent?<same query>. Unknown client_id or unregistered redirect_uri → 400 HTML page (never redirected). Other problems redirect to the client with error (unsupported_response_type, invalid_request — PKCE S256 is required — invalid_target, invalid_scope), state and iss. |
GET /api/oauth/consent?<authorize query> (per drive: /api/drives/{drive}/oauth/consent) | user | What the consent page shows: {client_name, redirect_host, redirect_uri, resource, requested_write, token_label, can_approve, role, drive, resource_drive} (see Drives) |
POST /api/oauth/consent | user | {…authorize params, approve: bool, write: bool, level?: 'view'|'edit', folder?: string} (level overrides write: the token's level; changes require edit access. folder: limit the connection to that folder — the token gets none at the root and its level there) → {redirect}: the client's redirect URI with code, state, iss (approve) or error=access_denied (deny). Approving requires an owner of that drive (local key, drive owner or org owner) — agents and editors/viewers get 403, matching who may create agent tokens; so does a resource naming another drive. |
POST /oauth/token | none (PKCE) | Form-encoded. grant_type=authorization_code (code, redirect_uri, client_id, code_verifier, resource?) or grant_type=refresh_token (refresh_token, client_id, resource?, scope?) → `{access_token, token_type: "Bearer", expires_in: 3600, refresh_token, scope: "read" |
The OAuth endpoints send Access-Control-Allow-Origin: * and Cache-Control: no-store.
Origin. URLs in metadata, iss and the expected resource use FLOCKFS_PUBLIC_URL (an origin such
as https://flockfs.example.com) when set; otherwise X-Forwarded-Proto/X-Forwarded-Host or Host,
assuming https unless the host is loopback. Set FLOCKFS_PUBLIC_URL behind a proxy that rewrites Host.
Resource (RFC 8707). resource, when sent (to authorize or token), must equal <origin>/mcp
(a trailing slash is ignored) or, with drives, <origin>/mcp/{drive} of an existing drive; otherwise
invalid_target. Omitted means <origin>/mcp. At the token endpoint <origin>/mcp (or omitted) means
the grant's drive; a drive URL must resolve to the grant's drive id (id, slug and slug-id forms are
equivalent), else invalid_target.
Tokens. The access token is an ordinary agent token (flockfs_…, stored as SHA-256 in auth_tokens):
read-only or read & write as chosen on the consent page, labelled after the client — e.g.
Claude (claude.ai) — created by the approving owner, attributed as agent:<id>, listed by
GET /api/tokens and revoked with DELETE /api/tokens/{id}. It expires after one hour.
- Codes: single-use, 60 s, bound to client, redirect URI, PKCE challenge and resource; stored hashed
(
oauth_codes). Replaying a redeemed code fails and revokes the token it issued. - Refresh tokens (
flockfsrt_…, stored hashed inoauth_refresh, valid 30 days from issue) rotate on every use. A refresh rotates the same agent-token row's secret and expiry, so one connection stays one entry in Access with one stableagent:<id>; the previous access token stops working. Presenting a rotated-away refresh token revokes the connection. Refresh fails once the agent token is revoked, once the approver is no longer an owner of the drive (member owner or org owner), or when a read-only grant asks forscope=write. - Hosted MCP caches a successful token check for up to a minute, so right after revocation an
initializemay still succeed, but every tool call is re-authorized by the API and fails.
Tables oauth_clients, oauth_codes, oauth_refresh are created idempotently at startup — in the
control schema, with each code's and refresh token's drive_id, on servers with drives (rows of a
pre-drives server are adopted for the default drive); the access token row is written in the drive's
own schema under that drive's lock.
Live WebSocket /live
First message {token, actor?, after} (type: "auth" is allowed and ignored). The server replays
every event after after, then:
| Server → client | Shape |
|---|---|
change | {type, event: Event} |
ready | {type, sequence, connection} — connection is this socket's id |
resync | {type, floor, sequence} — after was older than the kept history (see History retention): re-list; changes continue after sequence |
presence | {type, peers: [{connection, actor, kind: 'local'|'human'|'agent', display, email: string|null, id|null}]} — display per Display names; id is null for files this socket cannot read |
document | {type, file: File} (reply to open) |
ack | {type, id, revision, generation} (reply to update) |
awareness | {type, id, update, connection, removed?: true} |
error | {type, id, error}; a refused update adds code: "locked", lock: Lock |
locks | {type, locks: Lock[]} — all active locks; see Locks |
| Client → server | Shape |
|---|---|
open | {type, id} — returns document; also subscribes this socket to the file's awareness |
close | {type, id} — unsubscribe; relays removal of this socket's cursors in that file |
update | {type, id, generation, update} — base64 Yjs update (editors/owners only). Refused whole (nothing applied or stored) when it changes text another actor has locked; the client must then discard its local copy of that change (the web app reloads the document) |
presence | {type, id|null} — which file this person is looking at |
awareness | {type, id, update} — base64 y-protocols awareness update (no reply) |
Awareness (live cursors/selections): the server validates the update's wire format (≤ 256 KiB) and
relays it unchanged to every other socket that has opened that file, as
{type:"awareness", id, update, connection}. Clients apply it with
applyAwarenessUpdate(awareness, fromBase64(update), 'remote'). Sending awareness for a file also
subscribes the sender to it. The server remembers which awareness client IDs each socket announced per
file; when the socket closes (or sends close for the file) it relays
{type:"awareness", id, update, connection, removed: true} where update marks those clients
offline (state null, clock + 1), so applying it removes their cursors. Viewers may send awareness.
Credentials are rechecked on each non-awareness message and every ~500 ms; when access ends the server closes the socket.
Sessions
Every change event (/changes, live change, history export) carries session: the session it
belongs to. Agents name theirs with X-Flockfs-Session (set by flockfs run, flockfs mcp from
FLOCKFS_SESSION, hosted MCP from its request header, the SDK's session option, the live hello's
session). Requests without one continue the actor's latest session while it had changes in the
last 30 minutes, else start a new one (a person's natural burst of work in one sitting).
| Method | Path | Query | Returns |
|---|---|---|---|
| GET | /sessions | actor?, before? (a last_sequence), limit?=30 (≤ 200) | {sessions: Session[], next_before} newest first; sessions touching only paths the caller cannot read are left out, paths lists only readable ones |
Session = {session, actor, actor_name, started_at, ended_at, changes, paths: string[] /* ≤ 50 */,
first_sequence, last_sequence}Undo by session
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /undo | {session, dry_run?} | {plan: {actions, conflicts, hidden}, session, applied, failed} |
For every entry the session touched, each run of its changes is reversed with
merge3(base = after the run, ours = now, theirs = before the run): only the session's changes go,
everyone's later edits stay. Created entries are deleted (if unchanged since), deleted ones come
back (at a side path name (restored).ext if the path is taken), moves are moved back (unless moved
again since). What cannot be reversed cleanly is a conflict: a conflict block in text, else the
entry is kept as it is — never forced. The undo is recorded in its own new session (session in
the answer): redo = undo that session. Applying needs edit access to every
path involved. Entries the caller cannot read are left alone (hidden). dry_run only plans.
History before the drive's history window cannot be undone (reported as too_old). Unknown
session: 404.
Restore to a time
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /history/restore | {to /* ISO 8601 */, path?, dry_run?} | as /undo |
Every entry changed after to (inside path, a folder or file, when given) returns to its state
then: content, path and existence (files created since are deleted, deleted ones come back). The
same engine as undo, applied directly as a new session. to must
be inside the plan's history window (400 out of range otherwise: free 30 days, team 365).
Conflicts
Conflicts are information, not errors: when a merge or undo finds both sides changed the same region differently, the text keeps both versions in a marked block:
The block starts with <<<<<<< live [flockfs-conflict <id>], separates the current and
restored text with ======= <other version label>, and ends with
>>>>>>> end [flockfs-conflict <id>].
The opening line names the first side ("mine") and the block id; the separator names the other side ("theirs"). Editing the text by hand resolves it as well.
Binary files never get blocks: the other version is kept as a side copy.
CLI: flockfs sessions, flockfs undo --session S [--dry-run], and
flockfs restore --to 2026-10-04T15:00 [--path P] [--dry-run] (times without an offset
are UTC). Agents edit the live drive with their own token and session attribution.
Retained history budget
The hosted Free preset allows 256 MiB of accounted retained data per drive, in
addition to its 100 MiB current-file allowance. Team includes a separate pool of
5 GiB per purchased paid seat across the org, alongside its current-content pool
of 20 GiB per seat. Manually assigned Team orgs without a subscription use
max(1, collaborators) for both pools. limits.history_bytes reports the effective
budget and usage.history_bytes reports usage. history_scope is org for a
Team pool and drive for a per-drive budget. Older servers may omit these fields.
Accounting includes events, version bodies (including external blobs), CRDT checkpoints, the agent timeline and retained legacy overlays. It counts uncompressed payloads/metadata plus a 512-byte allowance per row. This application budget is not physical PostgreSQL disk: indexes, WAL, bloat, and object deduplication differ.
A transaction that exceeds the budget is rolled back: HTTP 413 or a live socket
error, with code: "quota_exceeded", resource: "history_bytes", scope: "org" or "drive",
limit and used. Existing file contents, revisions and history stay intact;
reads and exports remain available. Retry when the plan's retention window frees
space, upgrade Free, or add paid seats to grow a Team pool. There is no automatic eviction of recent history to make
room for a write. Existing over-budget drives remain readable; transactions that
reduce accounted usage are allowed. History pruning preserves live replay state
and anchor versions, so it may retain some older data necessary for correctness.
FLOCKFS_HISTORY_MAX_BYTES overrides the per-drive budget (unlimited disables
it). An explicit per-drive override applies independently of any pooled Team budget.
Self-hosted and Enterprise presets remain uncapped unless the operator configures limits.
Exclusive creation
POST /entries and POST /upload strictly create a new path. Concurrent creates
are serialized by the store transaction and unique path constraint: one succeeds,
the others receive HTTP 409. CLI put --exclusive and MCP write with
exclusive: true use these existing creation routes. No task or board schema is
required. The option is incompatible with a base revision.