Skip to content
decodecodeveloper docs
Storefront → Blocks → Engineering reference

Content protocol

How the site editor reads and writes saved blocks without running your code. Four JSON-RPC methods over one endpoint, served by deco serve and by the site editor's GitHub backend.

The site editor never runs your site's code. It reads and writes content through a small protocol, the content protocol, so any storage that implements it can back the site editor. This page is for contributors and for anyone writing another backend.

What it reads and writes

  • The schema, <root>/.deco/schema.gen.json. It's read-only: no method writes it.
  • The saved blocks, one JSON file per block in <root>/.deco/blocks. This is the only thing it writes.
  • The public key for secrets, <root>/.deco/secrets.pub, if there is one. describe returns it.

The root is the app root, the folder that contains .deco/, such as apps/storefront in a monorepo. describe reports it.

Where edits go

Site editor
Forms built from the schema; autosaves each edit
Content protocol
Four methods over one HTTP endpoint
Storage
Your working tree, or your GitHub repository

Two implementations serve the protocol:

BackendEditsThe site editor checks for changes
deco serve, from the Deco CLIThe files in your working tree. Your app hot-reloads after each save, and you commit when you're ready.About every 2 seconds
The site editor's GitHub backend, part of the hosted Deco CMSYour repository on GitHub, with draft branches and publishing.About every 30 seconds

The portable protocol has no event stream. The site editor polls with one batched request, and when nothing changed the answer is a short "not modified". Saving is last-writer-wins: if two people edit the same saved block at once, the later save replaces the earlier one.

In brief

The content protocol is JSON-RPC 2.0 (a small standard for calling named methods with JSON) over one HTTP endpoint, and a request can batch several calls. It has four methods:

MethodDoes
describeWhat this endpoint is: whether it writes git commits or a working tree, its app root, its limits, and how often to poll
schema.getReads the schema
blocks.listReads every saved block at once, with a revision for the whole set
blocks.applyThe only write: sets and deletes saved blocks together, all or nothing, optionally only if a block is still at a given version

Both reads take the version they already have and answer "not modified" when it's current, which is what makes polling cheap.

The wire format

One HTTP endpoint takes JSON-RPC 2.0 requests: POST <endpoint> with Content-Type: application/json and a body that's one request object or a batch array. The local server's path is /rpc; the site editor mounts its own per project.

  • params is always an object. A method without parameters accepts {} or no params.
  • Every request has an id. A request without one is rejected rather than run silently, because a dropped write is worse than an error.
  • Unknown parameters are rejected, so a guard the server doesn't understand never turns into an unguarded write.
  • Errors come back in the JSON-RPC error object with HTTP 200, except a missing or invalid bearer token on an endpoint that requires one, such as the site editor's GitHub backend (401), and a body over the size limit (413), which apply to the whole batch.
  • A batch runs in order and returns results in order, up to 10 calls. It isn't atomic: atomicity exists only inside one blocks.apply.
  • Responses are gzip-compressed when the request accepts it; schemas are often over a megabyte.

The four methods

function describe(): {
  protocol: "deco-content";
  version: { major: 1; minor: number };          // minors only add; a client refuses an unknown major
  server: { name: string; version: string };     // e.g. "deco-cli", "studio-github"
  kind: "working-tree" | "git";                  // The site editor hides publish and draft UI for a working tree
  readOnly: boolean;
  root: string;                                  // the app root: the folder that contains .deco/, relative to the repository root
  schemaFormat: "deco-meta@1";
  refs: null | { default: string; autoCreate: boolean };            // branches; null on the local server
  writes: { idempotency: null | { retentionMs: number }; schemaPreconditions: boolean };
  pollIntervalMs: number;                        // local: 2000; git: 30000, plus on window focus
  limits: { maxOpsPerApply: number; maxBlockBytes: number; maxRequestBytes: number; maxListBytes: number; maxSchemaBytes: number; maxBatchResponseBytes: number };
  preview: null | { url: string };               // the app the Preview tab loads; deco serve: --preview
  assets: null | { dir: string; urlPrefix: "/assets/"; maxBytes: number };   // dir relative to the repository root; null when read-only or uploads go to hosted storage
  secrets: null | { publicKey: string };         // the contents of <root>/.deco/secrets.pub, which the site editor encrypts Secret fields with; null without one
};
 
function schemaGet(params?: { ref?: string; ifNoneMatch?: string }):
  | { notModified: true; version: string }
  | { notModified: false; version: string; resolvedRef: string | null; schema: DecoMeta }
  | { notModified: false; version: null; resolvedRef: string | null; schema: null };   // no schema yet
 
function blocksList(params?: { ref?: string; ifNoneMatch?: string }):
  | { notModified: true; revision: string; resolvedRef: string | null }
  | { notModified: false; revision: string; resolvedRef: string | null;
      blocks: Record<string, object>;            // every saved block, by name
      versions: Record<string, string>;          // one opaque version per entry
      diagnostics: Diagnostic[] };               // files skipped or shadowed
 
function blocksApply(params: {
  ref?: string;
  requestKey?: string;                           // retry the same logical write; only when advertised
  ifSchemaMatch?: string;                        // reject if the schema changed; only when advertised
  set?: Record<string, object>;                  // whole-entry replace (create or update)
  delete?: string[];                             // a missing name counts as deleted
  ifMatch?: Record<string, string | null>;       // a version the entry must have; null = must not exist
}): { revision: string; versions: Record<string, string | null> };

Versions and revisions are opaque strings defined by each storage (a git blob hash on GitHub and on disk), compared only for equality and never across servers. Everything else is fixed inside the root: blocks.list and blocks.apply read and write <root>/.deco/blocks, and schema.get reads <root>/.deco/schema.gen.json, falling back to <root>/.deco/meta.gen.json, and parses it before serving, so a file caught mid-write is never served torn. With branches, a read of a branch that doesn't exist yet returns the default branch's content (reported in resolvedRef), and the first write creates the branch.

No schema yet

A site whose schema isn't generated yet (deco schema hasn't run) is a normal state, not an error. schema.get returns schema: null with version: null, and blocks.list and blocks.apply work as usual: without a schema there's no secret guard to apply and no ifSchemaMatch to send (one that's sent conflicts, with actual: null). There's no version to poll with, so the client polls schema.get without ifNoneMatch until a schema appears; the answer is a few bytes. The site editor lists every saved block meanwhile, grouped by __resolveType, and opens each one as plain fields inferred from its JSON, with a banner that says how to generate the forms. A site with no .deco folder at all is still NotFound.

describe doesn't report whether a schema exists: answering would mean reading the whole schema on every describe, and schema.get already says so in a few bytes.

blocks.apply is the only write:

  1. All or nothing. Every name in set and delete lands together (one commit on git) or none does.
  2. Durable when it returns. It resolves only after the commit or the file renames landed, which is what the site editor's autosave indicator relies on.
  3. set wins when a name is in both set and delete.
  4. Validated first. Names, value shapes, sizes and the secret guard are checked before anything is written, and every violation is reported at once.
  5. Preconditions are optional. A failed ifMatch writes nothing and returns a conflict with each entry's expected and actual version. Without ifMatch, the last writer wins, as with the site editor's autosave.

Renaming, duplicating and moving entries are recipes over one blocks.apply (a set plus a delete, with ifMatch: { [newName]: null } for create-only), not methods of their own. Creating and synchronizing hosted drafts, publishing, rollback, discarding a draft and the draft pointer stay site editor routes outside the protocol. The hosted editor binds its endpoint to the chosen internal draft; an editor does not need to supply or understand a Git branch. See Keeping drafts current.

Retry-safe writes and schema changes

A dropped HTTP response does not tell the client whether its save committed. describe.writes.idempotency advertises durable request-key receipts and their retention window; hosted Git storage must support them. A client creates one requestKey for a logical blocks.apply and reuses it only for retries of the identical request. A JSON-RPC id only correlates responses and is not an idempotency key.

Scope receipts to the authenticated tenant, principal, app root and resolved write target. Bind each key to a canonical digest of all supplied parameters. Return the original result when the same request is retried within the retention window, even if the branch has since advanced; reject reuse for different parameters with Invalid params (-32602). Recheck authorization before returning a receipt. After that window, the client rereads and reconciles; it must not blindly retry an uncertain old write as new work.

Persist the receipt with the mutation, or recover it from commit metadata. An in-memory map or a database insert after moving a Git ref cannot guarantee deduplication after a crash. Reserve concurrent requests with the same key, reconcile uncertain provider responses before retrying, and keep receipts for deleted entries too. Coalescing autosaves must preserve each request's result and guards; it cannot weaken the atomicity of an individual apply.

When schemaPreconditions is advertised, ifSchemaMatch guards against the form's schema changing before its save. A mismatch uses the Conflict error, with expected and actual schema versions. Recheck this and every per-entry ifMatch on the storage snapshot used for each atomic commit attempt, including after automatic synchronization. Receipts for completed identical requests are resolved before reevaluating those guards. Unsupported guards and request keys are rejected rather than ignored.

blocks.apply still replaces whole entries. Hosted draft synchronization uses the same whole-entry granularity: edited files and deletions favor the draft; untouched files adopt production. There is no implicit property patch behavior in this method. The protocol does not execute site code or claim that shape validation proves compatibility with deployed code.

Polling instead of events

There's no event stream in the portable protocol. Hosted Studio may invalidate reads through its existing events, but a client must also work by polling. A client polls with one batched request that carries the versions it has; when nothing changed, both answers are a few bytes:

A poll when nothing changed
→ [{"jsonrpc":"2.0","id":4,"method":"schema.get","params":{"ifNoneMatch":"9f2c…"}},
   {"jsonrpc":"2.0","id":5,"method":"blocks.list","params":{"ifNoneMatch":"4be1…"}}]
← [{"jsonrpc":"2.0","id":4,"result":{"notModified":true,"version":"9f2c…"}},
   {"jsonrpc":"2.0","id":5,"result":{"notModified":true,"revision":"4be1…","resolvedRef":null}}]

After its own write, a client adopts the returned revision, so its next poll is "not modified" unless someone else wrote. When the map did change, the client replaces its copy except for entries it's still saving.

Errors and limits

CodeNameWhen
-32001NotFoundNo .deco folder at all (the site editor's "not a Deco site"), or a branch that can't be created. No schema isn't an error: see No schema yet
-32002ConflictAn ifMatch or ifSchemaMatch precondition failed
-32003InvalidBlockA name or value breaks the rules below, or a Secret field holds anything but a secret block with a well-formed ciphertext (the secret guard)
-32005ReadOnlyA write to a read-only endpoint
-32006UnsupportedA feature the endpoint doesn't offer, such as branches on the local server
-32007LimitExceededToo many operations, batch entries or bytes
-32008UnavailableThe storage failed or is rate-limited, or retries ran out; carries retryAfterMs when known
-32010UnauthorizedMissing or invalid bearer token on an endpoint that requires one (HTTP 401); deco serve never returns it
-32011ForbiddenAuthenticated, but not allowed for this project

Plus the standard JSON-RPC codes (-32700, -32600, -32601, -32602, -32603). The defaults: at most 500 names per blocks.apply, 1 MiB per entry and 8 MiB per request. Reads are bounded too: describe reports maximum uncompressed schema, block-list and aggregate batch-response bytes, as well as the write limits. A storage can lower these limits. Existing files and conditional misses count against them; gzip does not bypass them. A list that is too large returns LimitExceeded, never a partial map presented as the whole snapshot. Choose and document hosted read defaults before release, measuring parsed-object memory as well as wire bytes.

File names

The site editor, deco serve and deco content import one rule from the protocol subpath, so an entry the site editor edits is the entry your app renders:

  • Name to file: encodeURIComponent(name) + ".json", directly in .deco/blocks (no subfolders). A page named pages-Home%20Page-6f1e is stored as pages-Home%2520Page-6f1e.json; collections/blog/posts/abc as collections%2Fblog%2Fposts%2Fabc.json.
  • File to name: decode the file name, without .json, exactly once. If decoding fails, the raw name is the entry name.
  • Two spellings of one name (files that decode to the same name after repeated decoding): one wins, the file whose entry has a path, then the one that took more decoding, then the lowest file name. The others are reported as diagnostics. A write overwrites the winning spelling and deletes the others in the same commit.
  • Names the site editor can't save: empty names; names containing \, .. or NUL; names whose encoded form is over 250 bytes, so the file fits a 255-byte file-name limit; a new name that differs from another entry's only in letter case; Windows device names such as CON; __proto__; and names ending in a source extension such as .ts or .tsx, which would shadow a module (those can still be deleted).
  • File content: JSON.stringify(entry, null, 2) plus a newline, UTF-8.

The local server

deco serve knows nothing about accounts: it prints its local address, inside the site editor link, and the site editor connects to it.

deco serve has no token or password, and it answers browser requests from any origin (CORS, with the request's Origin reflected), along with Chrome's local-network preflight. It listens on loopback only (127.0.0.1 and ::1, printed as localhost) unless you pass --host. Any website open in your browser can therefore read and write your content through it, so run it only while editing and stop it when you're done. The server rejects any Content-Type other than JSON on /rpc (uploads are the one exception, below). With --host set to an address other machines can reach, anyone on your network can read and write your content too, so the server prints a warning.

The site editor link is https://studio.decocms.com/site-editor#endpoint= followed by the encoded http://localhost:<port>/rpc. The site editor remembers the last endpoint in your browser, so opening /site-editor again reconnects to it; while the server is down or restarting, it shows that it's waiting for it and reconnects on its own. describe reports the app to preview as preview: { url }, the address given by --preview (by default your Vite config's port, else http://localhost:5173); the site editor's Preview tab loads only http or https URLs on a loopback host and shows no preview for anything else. To show one variant, it adds a ?__draft= pointer to this server's endpoint that forces that variant. The flow from the editor's side is in Edit on your machine.

Uploads aren't one of the four methods. The site editor sends each file to PUT /assets/<name> on the same server; on that path the server accepts the file's own image, video, font or PDF content type instead of JSON. It writes the file to the asset folder (public/assets, or --assets), never overwrites an existing file (a taken name gets a short suffix), and answers with the path the site editor stores in the field. describe reports where uploads go and the largest file it accepts in its assets field; with --read-only, assets is null and uploads are refused. The site editor's GitHub backend stores uploads in Deco's asset storage instead, so they never become commits. How to serve local uploads is in Images and other uploads.

The package

The protocol ships inside @decocms/blocks, under the protocol subpath, with zod as its only runtime dependency. The CLI (the cli subpath) and the site editor import it; the SDK's runtime never does, so it never reaches an app bundle.

@decocms/blocks/protocol             method types, errors, client, keys    (browser-safe)
@decocms/blocks/protocol/keys        the file-name rule above
@decocms/blocks/protocol/server      createContentHandler(storage): (Request) => Response
@decocms/blocks/protocol/storage/fs  the filesystem storage (Node only)
@decocms/blocks/protocol/conformance a black-box test suite over HTTP

A storage implements a small interface: a snapshot of names and versions, reading file bodies, reading the schema, and one atomic commit attempt. The core owns everything else: the JSON-RPC layer, validation, the file-name rule, the secret guard, limits and retries. The site editor's GitHub backend is one storage; the CLI's filesystem storage is another. The conformance suite runs against any endpoint, which is how both are kept to the same contract.

Conformance for hosted storage

The black-box suite must exercise atomic set/delete, set precedence, stale entry and schema guards, repeat requests after lost responses, simultaneous duplicate requests, receipt recovery after a simulated restart, tenant isolation and oversized reads and writes. A storage retry revalidates against the new head; a rate-limit response carries retry timing rather than triggering a burst of immediate retries. Versioned draft assets are served by the separate delivery contract, so a moving branch read is never mistaken for an immutable preview read.

Studio implementation

The Studio implementation handoff maps this design to existing Fast Preview modules and specifies persisted state, operation contracts, sequencing, initial limits, recovery scenarios and rollout phases.