API
Every HTTP route, what it needs, the JSON it takes and the JSON it returns.
The API is JSON over HTTP. Reading is open; writing needs a session cookie from GitHub sign-in. Everything the site does, it does through these routes.
Conventions
- Request bodies are JSON and capped at 1 MiB. A body that is not JSON answers
400. - Dates are ISO-8601 strings.
{owner}is a username, or the literalzeemafor the curated, owner-less designs.- Authentication is the session cookie. There is no API token in v1.
- Several write endpoints are rate limited per account, or per address for an anonymous caller.
Errors
Every failure has the same shape.
{ "error": { "code": 422, "message": "design is not structurally valid", "findings": [ { "id": "structure", "severity": "error", "message": "node orders-queue unknown component sqz", "blocking": true, "subject": { "type": "node", "ref": "orders-queue" } } ] } }
| Status | Means |
|---|---|
400 | The body is malformed, or a field is the wrong type |
401 | Not signed in, where signing in is what is missing |
403 | Signed in, but not allowed — writing someone else's public design |
404 | Not found, and what a private design answers to everyone but its owner |
409 | A conflict: a save against a stale hash, or an address already taken or reserved |
413 | The body, or the design, is too large |
422 | Structurally invalid; findings carries the blocking findings |
428 | A save arrived with no If-Match |
429 | Rate limited |
| Method | Path | Needs | What it does |
|---|---|---|---|
| GET | /api/v1/designs | nothing | List and search designs the caller may see. |
| POST | /api/v1/designs | viewer | Create a design from YAML. |
| GET | /api/v1/designs/{owner}/{slug} | nothing | One design with its owner, stats, permissions and head version. |
| PATCH | /api/v1/designs/{owner}/{slug} | owner | Change title, address, summary, README, visibility or tags. |
| DELETE | /api/v1/designs/{owner}/{slug} | owner | Delete a design; a published one leaves a tombstone. |
| PUT | /api/v1/designs/{owner}/{slug}/version | owner | Save a new version of the YAML. Requires the last-seen content hash. |
| GET | /api/v1/designs/{owner}/{slug}/versions | nothing | Version history: releases for a reader, every save for the writer. |
| GET | /api/v1/designs/{owner}/{slug}/summary | nothing | What a node referencing this design shows: name, numbered releases, counts and thumbnail. Public designs only; `version` picks a release. |
| POST | /api/v1/designs/{owner}/{slug}/publish | owner | Release the head as what readers see. |
| POST | /api/v1/designs/{owner}/{slug}/discard-draft | owner | Throw the draft away: the release becomes the head again and the versions since it are deleted. |
| POST | /api/v1/designs/{owner}/{slug}/clone | viewer | Copy a design into the caller's account. |
| GET | /api/v1/designs/{owner}/{slug}/yaml | nothing | The design source as text/yaml. |
| GET | /api/v1/designs/{owner}/{slug}/layout | nothing | The layout sidecar: node positions, edge bows and ports. `download=1` saves it as `<slug>.layout.json`, the file that goes beside design.yaml. |
| PATCH | /api/v1/designs/{owner}/{slug}/layout | owner | Choose the list badge (or null for automatic): a layout-only save of the head. |
| GET | /api/v1/designs/{owner}/{slug}/zip | nothing | The design as a folder: `<slug>.zip` holding design.yaml and design.layout.json. Takes `version`. |
| GET | /api/v1/designs/{owner}/{slug}/render | nothing | Server-side render: SVG or Mermaid. |
| GET | /api/v1/designs/{owner}/{slug}/thumb | nothing | The list thumbnail: the drawing as SVG on the row card's colour. `version`, `v` (its thumb key: a matching key is cached for a year) and `theme` (light or dark). |
| POST | /api/v1/designs/{owner}/{slug}/events | nothing | Record a view for the design's counters, once per visitor an hour. Exports and clones are counted by the server. |
| POST | /api/v1/lint | nothing | Structure and rules over a design text, without storing it. |
| GET | /api/v1/catalogue | nothing | Redirect to the current catalogue bundle. |
| GET | /api/v1/me | viewer | The signed-in account. |
| PATCH | /api/v1/me | viewer | Change the caller's display name or bio. |
| GET | /api/v1/users/{username} | nothing | A public profile, its stats and the designs the caller may see. |
| PUT | /api/v1/users/{username}/follow | viewer | Follow an account. Idempotent; 204. Limited to 60 follow changes an hour. |
| DELETE | /api/v1/users/{username}/follow | viewer | Stop following an account. Idempotent; 204. |
| GET | /api/v1/users/{username}/followers | nothing | Who follows an account, newest first, paginated. Public. |
| GET | /api/v1/users/{username}/following | nothing | Whom an account follows, newest first, paginated. Public. |
| GET | /api/v1/notifications | viewer | The caller's notifications, newest first; unread=1 for unread only. |
| POST | /api/v1/notifications/read | viewer | Mark notifications read, by id or all at once; 204. |
| GET | /api/v1/notifications/unread-count | viewer | How many notifications are unread. Cheap enough to poll. |
| GET | /api/v1/admin/users | admin | Accounts and their roles. |
| POST | /api/v1/admin/grants | admin | Grant a role. Idempotent. |
| DELETE | /api/v1/admin/grants | admin | Revoke a role. The row stays, marked revoked. |
List and search
GET /api/v1/designs?q=checkout&provider=aws&sort=updated&page=1&pageSize=24| Parameter | Values |
|---|---|
q | Free text over title, summary and the design's search text |
provider | A provider id, e.g. aws |
kind | A kind id, e.g. queue |
owner | A username, or me for the caller's own designs |
sort | updated, created, clones, exports |
page, pageSize | pageSize is capped at 100 and defaults to 24 |
{ "items": [ { "id": "0f2a…", "slug": "order-processing", "title": "Order processing", "summary": null, "visibility": "public", "origin": "reference", "updatedAt": "2026-01-14T09:31:02.000Z", "owner": { "username": "ada" }, "unpublished": false, "stats": { "viewCount": 128, "cloneCount": 4, "exportCount": 2 }, "providers": ["aws", "default"], "nodeCount": 9, "edgeCount": 11, "findingCount": 0, "brand": null, "sketch": null } ], "total": 1, "page": 1, "pageSize": 24 }
Visibility is part of the query, never a filter applied afterwards, so a private design cannot leak through a paging edge.
Create
POST /api/v1/designs Content-Type: application/json
{ "title": "Order processing", "slug": "order-processing", "summary": "The path a placed order takes.", "visibility": "private", "yaml": "schemaVersion: \"1\"\ntype: \"design\"\nid: \"order-processing\"\nnodes: []\nedges: []\n", "layout": null, "message": "first draft" }
title is required and at most 120 characters. yaml is required and is validated before
anything is stored: a structurally invalid design answers 422 and nothing is written. Without
slug, the address is derived from the title; where that is taken, a numeric suffix is added.
layout is optional: it goes through the same reader and limits as a save, and entries naming a
node, edge or group the YAML does not hold are dropped. A blocking finding carries line, the
1-based line of the text it is about, where one can be named. An edge end may name a group as
well as a node; edgeCount counts those edges like any other. Answers 201.
{ "design": { "id": "0f2a…", "slug": "order-processing", "owner": { "username": "ada" }, "address": "/d/ada/order-processing" }, "version": { "id": "9c11…", "contentHash": "5f3a…" } }
Read one
GET /api/v1/designs/ada/order-processing{ "design": { "id": "0f2a…", "slug": "order-processing", "title": "Order processing" }, "owner": { "username": "ada", "displayName": "Ada", "avatarUrl": null }, "stats": { "viewCount": 128, "cloneCount": 4, "exportCount": 2 }, "can": { "read": true, "write": false, "clone": true, "publish": false }, "currentVersion": { "id": "9c11…", "contentHash": "5f3a…", "createdAt": "2026-01-14T09:31:02.000Z", "message": "first draft" } }
currentVersion is the head for someone who may write, and the release for everyone else.
can is the authorization answer, and pages derive their buttons from it rather than
re-deciding.
Change metadata
PATCH /api/v1/designs/ada/order-processing{ "title": "Order processing", "slug": "orders", "summary": "The path a placed order takes.", "readme": "## Notes\n\nWhy the queue is where it is.\n", "visibility": "public", "tags": ["ecommerce", "aws"] }
Every field is optional; anything the body carries that is not one of them is ignored rather than refused, so an older client with an extra key still works.
summaryis cut to 280 characters.readmeis refused at over 20,000 characters (422) rather than truncated: silently losing the end of someone's writing is worse than making them shorten it.tagsis at most 10, normalised the same way an address is.sluganswers409when the address is taken, or was reserved by a published design.- Going public with nothing released releases the head, so the page is never empty.
Saving and conflicts
PUT /api/v1/designs/ada/order-processing/version If-Match: "5f3a…"
{ "yaml": "schemaVersion: \"1\"\n…", "layout": null, "message": "add the DLQ" }The last-seen content hash is required — as an If-Match header or as ifMatch in the body.
Without it the answer is 428. Where it does not match the current head:
{ "error": { "code": 409, "message": "the design changed since you loaded it", "findings": { "currentHash": "77be…" } } }
Nothing is ever merged for you. Reload, and decide.
A successful save answers with the new version, and unchanged: true where the YAML and the
layout were identical to the head, in which case no row was written:
{ "design": { "id": "0f2a…", "updatedAt": "2026-01-14T10:02:11.000Z" }, "version": { "id": "a4d0…", "contentHash": "77be…", "createdAt": "2026-01-14T10:02:11.000Z" }, "unchanged": false }
Publish
POST /api/v1/designs/ada/order-processing/publish{ "note": "first public cut" }{ "design": { "id": "0f2a…", "slug": "order-processing", "address": "/d/ada/order-processing", "publishedVersionId": "a4d0…" }, "version": { "id": "a4d0…", "contentHash": "77be…", "publishedAt": "2026-01-14T10:05:00.000Z" }, "unchanged": false }
The reply carries the slug because publishing an unnamed draft also moves it to an address made from its title, and the caller has to follow it there.
Clone
POST /api/v1/designs/ada/order-processing/cloneAn optional body may override slug, title, yaml or layout. Answers 201 with the same
shape as create. Requires a session.
Versions
GET /api/v1/designs/ada/order-processing/versions{ "versions": [ { "id": "a4d0…", "contentHash": "77be…", "message": "add the DLQ", "createdAt": "2026-01-14T10:02:11.000Z", "authorId": "3b77…" } ] }
A reader sees released versions only. The writer sees every save.
Summary (for references)
GET /api/v1/designs/ada/notification-system/summary?version=2What a node that references this design shows (ref in the YAML). Releases are numbered 1, 2, 3
in the order they were published; that number is what a reference pins.
{ "name": "Notification System", "owner": "ada", "slug": "notification-system", "release": { "version": 2, "id": "a4d0…", "publishedAt": "2026-01-14T10:02:11.000Z" }, "latest": 3, "releases": [ { "version": 1, "id": "19c2…", "publishedAt": "2026-01-02T08:40:00.000Z" }, { "version": 2, "id": "a4d0…", "publishedAt": "2026-01-14T10:02:11.000Z" }, { "version": 3, "id": "e7f1…", "publishedAt": "2026-02-01T16:20:45.000Z" } ], "counts": { "nodes": 27, "edges": 27, "groups": 2 }, "thumb": "/api/v1/designs/ada/notification-system/thumb?version=a4d0…&v=ee93…" }
releaseis the release asked for withversion, else the current one;latestis the current release's number. Both arenullfor a design that was never released.countsandthumbare those ofrelease;thumbisnullwith no release.- Public designs only, whoever asks, the owner included. Private, deleted and missing designs
all answer the same
404, so a reference never reveals which. Aversionthat is not a release answers404too. An address the design used to have (a rename) still answers. - Cached publicly for a minute, with an
ETag.
Representations
GET /api/v1/designs/ada/order-processing/yaml?version=a4d0&download=1Answers text/yaml with an ETag of the content hash. download=1 adds a
Content-Disposition so the browser saves the file instead of showing it.
GET /api/v1/designs/ada/order-processing/render?format=svg&theme=lightAnswers image/svg+xml, or text/plain for format=mermaid. ?download=1 saves it.
?version= renders a specific version.
GET /api/v1/designs/ada/order-processing/layoutAnswers the layout sidecar as JSON, or null where there is none.
?download=1 saves it as <slug>.layout.json instead: normalised, cut down to the ids the
version's YAML holds, keys in a fixed order, two-space indent. A version with no sidecar downloads
the empty one, { "version": 2, "nodes": {}, "edges": {}, "frames": {}, "notes": [] }.
?version= as for the YAML.
GET /api/v1/designs/ada/order-processing/zip?version=a4d0Answers application/zip, saved as <slug>.zip: design.yaml (the stored bytes) and
design.layout.json (as above), stored without compression. Unzipped, it is a repository design
folder. Import it again from New design ▾ Import YAML… (/new/import), which creates a
private draft through POST /api/v1/designs with yaml and layout.
PATCH /api/v1/designs/ada/order-processing/layout{ "badge": "provider:gcp" }Owner only. Sets the list badge: provider:<id>,
boundary:<id>, or null for the automatic one; anything else answers 400. It is a
layout-only save of the head, made the way the canvas saves (it folds into an unreleased head of
yours, or starts a new unreleased version), so readers see it once it is published. Answers
{ "version": { "id", "contentHash", "createdAt" }, "badge", "unchanged" }. 403 for a public
design you do not own, 404 for a private one.
Export events
POST /api/v1/designs/ada/order-processing/events{ "kind": "export", "format": "svg" }Only export is accepted here; anything else answers 400. This is how the export counter on a
design is fed. Answers { "ok": true }.
Linting
POST /api/v1/lint{ "yaml": "schemaVersion: \"1\"\ntype: \"design\"\nid: \"checkout\"\nnodes: []\nedges: []\n" }{ "valid": true, "findings": [], "counts": { "nodes": 0, "edges": 0, "groups": 0 } }
Nothing is stored. This is the editor's live feedback and the same answer the storage gate gives,
which is the point: the check a client runs for convenience is repeated at the point of storage.
valid is about structure alone — a design full of rule findings is still valid: true.
Accounts
GET /api/v1/me{ "user": { "id": "3b77…", "username": "ada", "displayName": "Ada", "avatarUrl": "https://…", "bio": null, "createdAt": "2025-11-02T08:00:00.000Z" } }
401 when not signed in. PATCH /api/v1/me takes { "displayName": "…", "bio": "…" };
displayName is cut to 80 characters and bio to 500.
GET /api/v1/users/{username} returns the same public shape plus the designs the caller may see
and stats: { designs, clones, followers, following, drafts }, where drafts is a number for
the account's owner and null for everyone else.
Follows
PUT /api/v1/users/{username}/follow DELETE /api/v1/users/{username}/follow GET /api/v1/users/{username}/followers?page=2 GET /api/v1/users/{username}/following
Following and unfollowing are idempotent and answer 204. Following yourself is 422, an
unknown or closed account 404, no session 401, and more than 60 changes in an hour 429.
The lists are public and answer
{ "users": [{ "username", "displayName", "avatarUrl" }], "page", "pageSize", "total" }.
See profiles, follows and notifications.
Notifications
GET /api/v1/notifications?page=1&unread=1 GET /api/v1/notifications/unread-count POST /api/v1/notifications/read
{ "ids": ["5f0c…", "9a1e…"] }The list answers { items, page, pageSize, total, unread }; each item is
{ id, kind, createdAt, read, actor, design }, where actor and design are null once the
account is closed or the design is no longer yours to read. unread-count answers { "count": 3 }.
Marking takes { "ids": [...] } (at most 100) or { "all": true } and answers 204; ids that are
not yours are ignored. All three need a session.
Administration
These need an acting admin — an account holding the role and with the session switch on. See roles and admin presence.
GET /api/v1/admin/users?q=ada&admins=1 POST /api/v1/admin/grants DELETE /api/v1/admin/grants
{ "username": "ada", "role": "admin", "note": "on call rotation" }Granting is idempotent: 201 the first time, 200 afterwards. Revoking keeps the row and marks
it revoked, so the history stays. An admin cannot revoke their own admin role.
The rest of the surface
| Method | Path | Needs | What it does |
|---|---|---|---|
| GET | /api/health | nothing | Liveness and a database round trip. |
| GET | /api/metrics | token | The Prometheus scrape target, behind METRICS_TOKEN. |
| GET | /api/reference/{type}/{id} | nothing | The Markdown rendering behind /catalogue/.../<id>.md. |
| GET | /api/auth/github | nothing | Start the GitHub OAuth flow. |
| GET | /api/auth/github/callback | nothing | Finish it and open a session. |
| POST | /api/auth/logout | viewer | End this session, or every session of the account with the form field scope=all. |
| POST | /api/auth/mode | admin | Turn admin presence on or off for this session. |
/api/metrics answers 404 — not 401 — when METRICS_TOKEN is set and the caller does not
present it. An endpoint that admits it exists is an endpoint worth attacking.
Machine-readable catalogue
Not JSON, but part of the same contract:
| Address | Content type | What |
|---|---|---|
/catalogue/components/<id>.md | text/markdown | A component page with front matter and the verbatim YAML |
/catalogue/kinds/<id>.md | text/markdown | The same for a kind |
/catalogue/rules/<id>.md | text/markdown | The same for a rule |
/llms.txt | text/plain | The index over all three, per the llms.txt convention |
/catalogue/catalogue.json | application/json | The compiled bundle the browser loads |
Catalogue reference explains what is on each of them.