Skip to content
Fermium

Building a sync client

How to keep a local mirror of documents in step with Fermium using the change feed, per-version manifests, SHA-256 diffing, conditional reads, and version-checked pushes.

A sync client keeps a directory on disk equal to a set of documents in Fermium, and pushes local edits back as new versions. Everything it needs is in the editing endpoints: a change feed with a cursor, a manifest per version with a hash per file, raw file reads that honour If-None-Match, and writes that carry base_version. This page is the recipe. Agents get the same feed through list_changes and the same write through write_file.

Authentication

The REST endpoints below accept the app session cookie or, for a desktop or scripted client, Authorization: Bearer <token> with a static dsk_ token or an OAuth access token. See Authentication. The same feed and the same writes are available as the MCP tools list_changes, get_file and write_file; the parameters and cursor semantics are identical, and the rest of this page applies to both.

The loop

cursor = stored cursor, or nothing on first run
repeat:
  page = GET /api/changes?since=<cursor>&root=<root>&folder=<folder>
  for each change in page.changes (oldest first):
    if change.deleted: remove the local copy, forget its state
    else: reconcile(change)
  cursor = page.next_cursor; store it
until not page.has_more
sleep; repeat

GET /api/changes returns documents whose changed_at is after the cursor, oldest first, at most 200 per page, with has_more when a page was full. The cursor is opaque; store it as a string and send it back unchanged. It encodes the last changed_at and document id, so a document that changes again after you saw it comes back on a later page with a later cursor position. Nothing is ever skipped between two consecutive cursors.

Scope the feed to what you mirror. Every document lives in one root, and root picks which one the feed covers:

root The feed carries
omitted the caller's My documents; with include_team=true also team-listed documents and every shared root the caller can see
~ personal only: the caller's My documents, plus team-listed documents with include_team=true, and never a shared root
general, or a shared root's slug exactly that root, as far as the caller may open it; include_team is ignored

folder narrows the feed to one folder path (recursive=true, the default, includes its subfolders). The same path can exist in several roots, so a client that mirrors a shared root should run one feed per root, each with its own stored cursor and its own local directory, rather than one wide feed filtered client-side. Use root=~ for the mirror of someone's personal documents so that documents from shared roots do not land in it.

What the feed includes

The feed applies the same access check as opening a document. Your own documents in My documents are always present. A document in a shared root appears only while the root's access list lets you open it, whoever wrote it, yourself included. With include_team, a colleague's document appears only if you may open it: team-listed documents no grant has narrowed away from you, and documents shared with you through a grant. A document you may not open is skipped entirely, with no title, manifest or deletion marker, and the cursor still advances past it. Access can change after you mirrored a document: a grant removed later makes the document disappear from the feed rather than arrive as a deletion, so a mirror that must reflect revoked access should periodically re-check what it holds with GET /api/docs/{id} and drop what answers 404 or 403. A feed scoped with root behaves the same way when a document moves to another root: it leaves this feed without a deletion marker and shows up in the other root's feed, so the re-check should also compare the document's root with the root being mirrored. The same rule governs GET /api/tree, bundles, ranked search, agent payloads and file history.

What a change carries

Each change is one document with its current version, folder, root ({id, slug, name, kind}, or null for a personal document), tags, title, kind, owner_email, listing, updated_at, changed_at, deleted, and a manifest mapping every path in the current version to {sha256, size, content_type}. Two things to notice:

  • changed_at is not updated_at. updated_at moves only when content is written and drives retention. changed_at also moves on a rename, a move to another folder or another root, a tag change, a visibility or listing change, a description or alias change, a legal hold, and a deletion. A client keyed on changed_at therefore sees a document reappear under a new folder without any file having changed.
  • The manifest is the whole current version. You do not need a second request to learn which files a document has. The hash is the file's SHA-256, the same value list_versions and GET /api/docs/{id}/versions report, so you can compare it against a hash you computed locally.

Reconciling one document

For a change with deleted: false:

  1. Work out the local directory from folder and the document (a mirror usually uses <root>/<folder>/<title or doc_id>/). If the document moved, move the directory.
  2. For each path in manifest: if there is no local file, or the local file's SHA-256 differs from manifest[path].sha256, download it. Otherwise skip it.
  3. Delete local files whose path is not in the manifest.
  4. Record version and the manifest as the document's synced state.

Download with GET /api/docs/{id}/files/{path}. The response's ETag is "<sha256>"; send it back as If-None-Match on the next fetch and an unchanged file answers 304 with no body. Since the manifest already tells you the hash, you will rarely need the conditional request for files you track, but it is the right way to re-validate a file you are unsure about, and it costs nothing when the file did change. X-Doc-Version on the response names the version you received; compare it to the version in the change and, if it is newer, expect another change on the next page.

To read a file as it was in an older version, add ?version=n. GET /api/docs/{id}/manifest?version=n gives the file set of any version, which is how a client shows local history or rebuilds a state it lost.

Deleted documents

A deleted document appears in the feed with deleted: true, deleted_at, and an empty manifest, and keeps appearing in the window after the cursor for 30 days after deletion. Remove the local copy and drop its state. A mirror that has been offline for more than 30 days cannot rely on the feed to learn about deletions in that gap: on such a resume, list what you hold, fetch GET /api/docs/{id} for each, and drop the 404s. A document that expired is served the same way once it is purged; before purge it still lists.

Pushing local edits

Write one file with PUT /api/docs/{id}/files/{path}:

{ "content": "...", "base_version": 7, "encoding": "utf8" }

Always send base_version: the version you recorded when you last reconciled the document. The server writes the file as version 8 only if the document is still at 7. Three outcomes:

  • 200. The response carries the new version and the file's sha256 and size. Record them; the next feed page will also carry this change, and its manifest will match what you have, so nothing is re-downloaded.
  • 409. The document moved on. The body carries current_version, changed_by, and diff, a unified diff of that file from your version to the current one ("" if this file is unchanged and another file moved, null if the file is not text). Pull the current version first, then decide: merge and retry with the new base_version, or keep both by writing the local copy under a different name, as most sync tools do with name (conflict).ext. Do not retry blindly with the current version; that is exactly the overwrite base_version exists to prevent.
  • 423. Another user holds the document's edit lease. The body names the holder and expires_at. Back off until then and retry; leases last at most ten minutes and clear themselves. Show the holder to the user rather than spinning.

Binary files go up the same way with "encoding": "base64". A whole-directory change is one PUT per changed file; each is a version, so compare hashes first and push only what differs. Writes count against the account's publish quota of 120 per hour; a client that syncs a large tree should respect that budget and never push files whose hash already matches the manifest.

If a client needs several writes to land as one uninterrupted sequence, or a person may be editing the same document in the browser, take the lease first with POST /api/docs/{id}/lock and pass its token as lock_token on each write, renew it if the work runs long, and release it as soon as the last write lands.

Files you should not push: the generated index.html of a markdown, snippet or file-share document is refused (the source is what you edit), and a document's rendered page always regenerates from its source on the server. Text files are limited to 1 MiB as text; all files in a document add up to at most 20 MiB.

Bundles for one-off mirrors

When you need a folder on disk once, to grep it or hand it to a tool, the change feed is more machinery than the job needs. POST /api/bundles (or download_bundle from an agent) zips the current version of every document under a folder that you may open and returns a signed link, good for 10 minutes and one download, bound to your account. The zip holds <folder>/<title>/<files> per document with a meta.json (id, alias, tags, version, url) and an index.json at the root that lists every document and the count skipped by access rules. Limits: 500 MiB uncompressed per bundle, 6 bundles per hour per account. Details are in the REST reference.

A bundle is a snapshot, not a mirror: it carries no cursor, and edits made to the unpacked files are not pushed anywhere. To write a change back, use PUT /api/docs/{id}/files/{path} with the version from meta.json as base_version, exactly as above. For anything that should stay in step, use the feed.

Polling and backoff

Poll the feed at a modest interval and only while the client is active. Every response is small when nothing changed (changes: []). Back off on 429 and 5xx, and on 423 for the specific document. There is no push channel today; the feed is the contract.

Checklist

  • Store the cursor after each page; never re-derive it from timestamps.
  • Compare SHA-256 before downloading or uploading anything.
  • Send If-None-Match when you re-validate a file.
  • Send base_version on every write; treat 409 as a merge, not a retry.
  • Honour 423 by waiting for expires_at.
  • Handle deleted: true, and rebuild from GET /api/docs/{id} after more than 30 days offline.
  • Move directories on a folder change instead of re-downloading.