vitrinka.docs

HTTP API

Every surface is reachable over plain HTTP, and publishing needs nothing but tar and curl. This is the contract the CLI, the MCP server and the recorders all speak.

Authentication

Every request is authenticated. vitrinka auth login mints a personal token and MCP hosts hold an OAuth token; both act as you, so name the workspace per request with X-Vitrinka-Workspace: <slug> — the CLI sends it from the repository's binding. Without the header, a personal token resolves only when you belong to exactly one team workspace, or when the project in the path exists in exactly one of yours — otherwise a structured 401 lists your workspaces; an OAuth token without it lands in its connection's default workspace, and one naming a workspace the connection does not reach gets 403 workspace_not_connected. Service and agent tokens are pinned to one workspace. Recorder tokens (vkr_) and report-widget keys (vkb_) are publishable and origin-allowlisted: a report key can only ingest into its one project, and a recorder token acts only on the sessions it started itself (recorder credentials). Browsers carry a session cookie. The tenant router resolves the workspace, then role-gates the request.

curl -sS -H "Authorization: Bearer $VITRINKA_TOKEN" \
  -H "X-Vitrinka-Workspace: acme" \
  "$VITRINKA_URL/api/v1/projects"

Board writes are additionally attributed, which is not the same as authenticated: an agent token's granted identity wins, then a declared X-Board-Actor, then the signed-in user. A browser cannot use X-Board-Actor to act as someone else.

Recovering from errors

API failures preserve their HTTP status and return error, code, detail and fix. Specific failures also include currentRevision, didYouMean, or validation diagnostics; preserve these when forwarding an error. Unknown JSON fields are rejected with accepted field names. Bodies must contain exactly one JSON value within the endpoint's size limit.

Read code to classify the failure and fix for the next action. A 401 may require authentication; a 403 calls for the correct workspace, role or scope, or reports a limit reached or writes paused by an expired licence — retrying unchanged does not help. Before retrying a write after a timeout or gateway failure, read the resource to check whether the write already took effect. Respect Retry-After and Allow. OAuth endpoints retain their OAuth error contract.

MCP tool failures set isError: true and expose the same diagnostic object in both text content and structuredContent. Protocol failures retain their JSON-RPC code and put recovery details in error.data.

Health

The container's own health check. No authentication.

curl -sS "$VITRINKA_URL/healthz"
# {"ok":true,"mode":"multitenant"}

Publishing a set

The canonical one-liner. It syncs a directory as a set and is idempotent — re-run it to replace the content in place.

tar czf - -C ./screenshots . | curl -sS -X PUT --data-binary @- \
  -H "Authorization: Bearer $VITRINKA_TOKEN" \
  "$VITRINKA_URL/api/v1/sets/myapp/main/s-20260823-1431/content?kind=screenshots&commit=a34ec8e&pr=42"
  • The key is yours — {key} is a client-generated alias matching [a-zA-Z0-9._-]{1,64}, so you know the blob addresses before the first byte uploads; the url in the response is the set's home (a document card /a/<id> or a journey board /boards/<slug>).
  • No key handy? — POST /api/v1/sets?project=…&branch=… generates one for you.
  • First upload → 201 — with a fresh version. Re-PUT of the same key → 200, same version, content replaced.
  • Or one file at a time — a screenshot set also syncs file by file, the way vitrinka board push does: GET …/{key}/files is the have-list (path → sha256), PUT …/{key}/files/{path} with X-Content-SHA256 uploads one file of up to 25 MiB (If-None-Match answers 304 for bytes the set already holds), and POST …/{key}/commit prunes what the folder lost and writes the set's metadata. Landed shots join the set's board within seconds. Artifact sets keep the tarball door.

Publish parameters

All optional, all query-string. They are what stops a screenshot from being orphaned from the code that made it.

ParameterDefaultWhat it carries
kindscreenshotsWhat sort of set this is.
branch—The branch the work happened on.
commit—The commit that produced it.
pr—Pull-request number.
issue—Tracker reference — a Plane issue gets one upserted comment per set.
title—Human title for the set.
repo—Repository the set came from.
source—Cross-link a source set, project/branchSlug/key.

MAX_SET_MB (default 200) caps both the request body and the unpacked size of the tarball door. The file-by-file door caps each file at 25 MiB; a set holds any number of files, and its have-list (GET …/files?limit=N&after=<next>) reads in pages.

Publish response

{
  "url": "…",
  "project": "myapp",
  "branch": "main",
  "version": 7,
  "key": "s-20260823-1431",
  "kind": "screenshots",
  "files": 24,
  "bytes": 5312004,
  "created": true
}

created is true on the first upload of a key. An artifact push adds diagnostics, screenshot (the smoke render), docCard (the document's /a/<id>) and pages.

The manifest

A manifest.json inside the tarball drives gallery badges, routes, notes and day grouping. A set with no manifest still ingests — you lose the metadata, not the upload.

{
  "version": 1,
  "shots": [
    { "file": "01-cart.png", "surface": "cart", "route": "/cart", "note": "discount applies twice", "ts": "…" }
  ]
}
  • Version 2 adds — per-shot src[], state, device{} and journey, plus session-level commit and branch.

JSON endpoints

EndpointWhat it does
GET /api/v1/projectsEvery project.
GET /api/v1/sets/{project}[/{branch}]Set listings.
GET /api/v1/sets/{project}/{branch}/{selector}One set's metadata.
PATCH /api/v1/sets/{project}/{branch}/{selector}Edit {"name","title"} — the name is a lowercase slug, unique per project and branch.
POST /api/v1/setsGenerate a set key.
PUT · GET /api/v1/projects/{project}/indexPush or read the @ autocomplete index.
PUT · GET · DELETE /api/v1/projects/{project}/icon · POST …/icon/fetchA project's icon: PUT takes the image bytes as the body (the type is sniffed; normalised to a PNG of at most 256 px, a safe SVG kept), POST …/icon/fetch {url?} reads it from a public site — the production domain by default, refused on an air-gapped install. POST /api/v1/workspace/logo/from-project {project} makes one the workspace logo (admin).
GET /api/v1/libraryThe cross-set screenshot search canvas. Also /facets and /screen?key=.
GET /api/v1/artifactsThe one artifact door — doc and page cards and artifact sets across projects, keyset-paged (?cursor=, ?limit=); narrowed by ?project=, ?tag=, ?kind=, ?branch= (comma lists) and ?q=; ?facets=1 adds the kinds, branches and projects to a first page. The Library's Artifacts and a project's Record › Artifacts both read it.
GET /api/v1/library/boards?limit=The board browser's page — every live board newest activity first, keyset-paged (?limit= up to 200 selects this shape, ?cursor= continues it); narrowed by ?project=, ?type=, ?tags=, ?fav=1 and ?q=; ?type= names one of the six board kinds (docs, artifacts, brainstorm, qa, sessions, board; any other word narrows by the stored board type) and ?facets=1 adds the kinds with their counts to a first page; every row carries its kind. A cursor replayed under other filters is refused, naming the filters it was cut under.
GET /api/v1/library/favoritesYour favorites as one list — ?scope= (all, mine or workspace), ?folder=, ?kind=, ?project= — with the three scopes' counts.
GET /api/v1/memoryThe Library's flat memory list — every project's memory document, plus a signed-in person's memory pages, keyset-paged (?project=, ?cursor=). Each row names its source (project · personal); ?facets=1 adds the projects holding a memory to a first page.
PUT /api/v1/projects/{project}/islands/{island}/snapshots/{commit} · GET …/current · PUT …/usage · GET …/note?path=The memory islands cloud cache: memo publishes one snapshot of a merged commit per island ({island} URL-escaped, project%2Facme) through vitrinka memory snapshot put; current moves only by compare-and-set on the snapshot's parent (409 cas_mismatch names the cloud's), a malformed snapshot is 422 snapshot_invalid with its field. The cache keeps 20 snapshots per island. GET /api/v1/projects/{project}/memory/index is the bounded index MCP get {kind: "project_memory"} reads — pinned rows, then kind × recency plus synced usage, {commit, complete, omitted, next}; once a snapshot carries notes/legacy-project-memory.md, PUT …/memory answers 409 legacy_put and keeps the body as a proposal (GET …/memory/proposals).

Share URLs

These are the human-facing addresses a set gets. They are not under /api/v1. The one to send to somebody is the url in the publish response — the set's home, a document card or a journey board; the old set addresses only redirect there.

URLWhat it serves
/{project}/{branch}/{version}301 to the set's home: its document card /a/<id> (doc.json → doc card, index.html → html card, other files → file-list doc card) or its journey board /boards/<slug> (screenshots).
/{project}/{branch}/latest · /{key} · /{name}302 to the same home.
/{project}/{branch}/{version}.zipA streamed zip of the whole set.
/{project}/{branch}/{version}/files/{path}Raw bytes, with an ETag and a long cache.
/{project}/{branch}/{version}/raw/{path}The same bytes, CSP-sandboxed — this is the html card's iframe source.
/boards · /boards/{slug}Annotation boards — /boards lists them as cards, mini cards, a table or the zoomable wall, newest activity first (?layout=, plus the wall's ?project=, ?tag=, ?type=, ?fav=1 and ?q=); /boards/{slug} of a docs board opens its reader at /docs/{slug} (?canvas=1 keeps the canvas).
/docs · /docs/{slug} · /docs/artifactsDocs — every project's docs boards (GET /api/v1/docs-boards), one docs board read as contents and pages (GET /api/v1/boards/{slug}/docs; ?page= names the open page), and every artifact as cards, mini cards or a table.
/libraryThe Library module — redirects to /library/screens, the cross-set screenshot search canvas; /library/memory the memory list, /library/favorites your favorites as one list (All · Mine · Workspace). /library/boards and /boards/library redirect to /boards, /library/artifacts to /docs/artifacts.

A set can carry an editable name — a lowercase slug, unique per project and branch — so …/myapp/main/payout-flow redirects like …/myapp/main/2.

The other families

Sets are the oldest door; the product has grown many more under /api/v1, and the CLI, the MCP endpoint and the browser all speak them. Every family pages — nothing reads a whole project. Board writes are attributed as above; task-engine writes record the verified identity only — an X-Board-Actor label never becomes the ledger actor.

FamilyWhat it carries
boards/{slug}The board itself: cards, layout, bulk edits, live events over SSE and WebSocket, uploads, imports, members, messages, strokes, edges, and undo.
cards/{id}One card: element payload, file, face, versions, restore, OCR, the set-map its ref elements define.
annotations/{id}The work-queue wire: capsule, messages, refs, snapshot, markup, revisions, attempts, release and cancel, and the door that files a task.
boards/{slug}/annotations/batch · …/agent-verdictAgent-authored findings: the batch door files up to 100 native annotations (born staged, regions in source-image pixels of a declared cardVersion, idempotent per key, author agent:<runtime> beside the verified principal) — the MCP annotate tool; agent-verdict is the human's Accept / Dismiss, never exposed to agents. boards/{slug}/cards/{cardId}/crop.png answers with X-Source-Width, X-Source-Height and X-Card-Version, the space those regions live in.
work · work/wait · work/listenersThe annotation work wire that work watch and wait_for_work read, and the listener leases.
sessions/{id}Recorded sessions: events, shots, chunks, digest, rrweb, steps, and the live pairing channel.
reports/{id}Recorder reports from the embeddable widget: approve, reject, redact, promote.
tasks · comments · rules · views · statesThe task engine — below.
templatesThe board template registry get_templates and save_template read and write.
docs · artifact-docsThe unified reference the docs tool and vitrinka docs read.
diary · personal/{period}The day boards Snap posts to, and their weekly, monthly and yearly summaries.
boards/{slug}/cards/{id}.pdf · .eml · .docxA document card as a file: PDF from the board's own painter, an email draft with inline images, a Word document; ?audience= filters it.
eve/{path}The AI backend proxy — below.

The task engine

Tasks are keyed by id across the workspace and scoped to a project on listing. A list takes the same filter document the browser's filter bar edits, ?f=<base64url json>, and pages with a keyset cursor that carries its own order — a cursor from one order is refused on another.

EndpointWhat it does
GET /api/v1/projects/{project}/tasksThe filtered, ordered, paged list; the filter document's created and updated windows narrow by when a task was filed and last touched. ?ask=<phrase> has Eve compile a phrase into that document and echoes it as compiled — the next page passes it as f with the cursor; with no AI backend it answers 503 naming the doors that need none. Also states, labels, sprints, milestones, views, rules, intake beside it.
POST /api/v1/tasks · PATCH /api/v1/tasks/{id} · DELETE /api/v1/tasks/{id}Create, update, soft-delete. POST /api/v1/tasks/bulk moves many at once.
POST /api/v1/tasks/{id}/rankManual order — before or after another task.
GET /api/v1/tasks/searchRelevance-ranked search over everything a task carries (title, body, fields, refs, comments), across projects or inside one: accent-insensitive, a title match outranks the rest, every word before any word, a typo also searches its correction; each hit says where it matched (matchedIn, matchedLabel, corrected). Narrowed by type, state, parent and epic (any depth); a facet naming nothing answers 400 with the allowed values and the nearest. A task the query names by id (#12, vt-12, SHO-12, <workspace>/12, its URL, a bare 12 as the whole query) leads the hits, marked pinned. Otherwise, a query with no word of 3+ characters answers 422 naming the floor. compact=1 answers one-line rows, each carrying project when the search spans projects. GET /api/v1/tasks/{id}/search searches inside one task and its subtree — fields, refs and comments included.
GET /api/v1/searchWorkspace-wide search in one call: tasks, annotations (by number: №432), pages, boards, tags, cards, sessions, shots, each ranked and bounded, and only the groups that ran come back; any fragment of three or more characters matches, mid-word included; project, type, state, tags narrow, and a value naming nothing answers 400 with the allowed values and the nearest; totals counts returned hits. Task and page hits carry their canonical url; a task named as #12, its key (FIX-12), <workspace>/12 — another workspace you belong to included — or its URL is pinned first. mode=resolve reads q as ONE task reference (title words, any id spelling, a URL, a branch, bugs under <epic>, mine) and answers a verdict (exact, confident, ambiguous, none), interpreted, at most 5 candidates with parentId, childCount and why, and next.
GET /api/v1/resolvePlatform root (also /w/{slug}/api/v1/resolve): ?url= a pasted app link → the task, project, board or card it names, in any workspace the user belongs to, with foreign relative to ?from=. One 404 for anything the user cannot see.
POST /api/v1/memory/resolvememo's recall --deep (vitrinka memory resolve): {query, scope, candidates, budget: {k}} (≤ 40 candidates, titles only) → Eve's ranking {ranked: [{id, rev, score, why}], dropped?} of only the candidates sent, at their revisions. Synchronous (7 s); a refused field answers 422 naming it, an unavailable backend 404 not_configured.
POST /api/v1/projects/{project}/askOne plain-language question over a whole project, {question, intent?, budget?} — "what blocks 4.0.0?": Eve reads the project's tasks and answers {answer, tasks, confidence, intent, actor}, each task with its id, key, title, type, state, url and why, only live tasks of that project. Costs AI credits; without an AI backend it answers 404 naming search as the door that needs none. POST /api/v1/tasks/{id}/ask asks one task's corpus and answers with citations.
GET /api/v1/tasks/{id}/comments · links · refs · events · runsA task's comments, typed links, attached refs, its ledger, and the AI runs on it.
POST /api/v1/projects/{project}/intakeThe intake door — the only way a proposal becomes a draft. POST /api/v1/tasks/{id}/intake gives the verdict.
POST /api/v1/projects/{project}/sprints/{id}/start · completeSprint lifecycle; complete carries unfinished work.
GET /api/v1/rules/schema · POST /api/v1/rules/{id}/dry-runThe rule document's JSON schema, and a replay over the recent ledger without writing.
GET /api/v1/me/workThe signed-in identity's cross-project desk: assigned, created, mentioned, overdue.
GET /api/v1/tasks/{id}/pickup · POST /api/v1/tasks/{id}/handbackThe work loop's two ends — work loop.
POST /api/v1/tasks/{id}/completeThe one todo-completion door.
POST /api/v1/projects/{project}/todos/fire · GET /api/v1/me/ripeThe one evaluator of typed todo conditions — todos.
POST /api/v1/tasks/{id}/run · GET /api/v1/projects/{project}/qa/runsThe one door from a runner manifest to the QA record (vitrinka qa run), and the run list.
GET /api/v1/projects/{project}/release · POST …/release/refresh · cut · notesThe release track: its windows and docHeadSha (the head the page was written for), the sweep (until pins a window end to a commit), the cut (expectHead refuses a moved window), the linked notes document.
/api/v1/projects/{project}/fieldsCustom-field definitions.
/api/v1/projects/{project}/work-timeWork-time events, forecast and export — work time.
POST /api/v1/projects/{project}/deploysRecord that a commit reached an environment, so deployed <env> todos fire; a production mark answers its History lineage.
POST /api/v1/projects/{project}/memory/watches · GET …/memory/watches/completionsmemo's for: human watch rows, mirrored to ONE todo per row (vitrinka memory watch put), and the todos completed here waiting for memo to journal them done.
POST /api/v1/projects/{project}/merge · transferMerge a project into another of the same workspace, or copy it into another workspace you own — workspace owner only.
PUT /api/v1/projects/{project}/mirror · POST /api/v1/projects/{project}/mirror/syncBind the Jira mirror (connector, truth side, state map) and run a sync — admin only.

Binding a mirror, running a sync, authoring rules and recording deploys are admin actions, and merging or transferring a project is the workspace owner's; everything else is member-level. The full model is the Product tab.

Live events

GET /api/v1/events is one Server-Sent Events stream per workspace — the bus the browser app itself runs on — so a script can follow the workspace instead of polling list endpoints. ?project= narrows it to one project; ?kind= (repeatable, or comma-separated) picks from task, annotation, board, sidebar, favorites, workspace-settings, prefs, project, agenda and decision, and an unknown kind is a 400 that names it.

curl -sN -H "Authorization: Bearer $VITRINKA_TOKEN" \
  -H "X-Vitrinka-Workspace: acme" \
  "$VITRINKA_URL/api/v1/events?project=shop&kind=task,annotation"

Each frame is id:, event: (the kind) and data: — one ledger row with its kind, project, ref, actor, createdAt and a typed data payload — with a comment ping every 25 seconds. Resume with the Last-Event-ID header, or ?after=<id>; without a cursor the stream starts at the head. Rows are durable and kept 30 days, so a reconnect after sleep or a deploy replays everything it missed. It authenticates like any /api/v1 call, workspace included.

A decision frame follows Needs you (GET /api/v1/human/decisions): one decision moved in or out of it, whichever door moved it — the app, the CLI, MCP, an agent's hand-back, a rule. Its ref is the decision's id in that read's grammar, and data says what happened:

{ "id": 4182, "kind": "decision", "project": "shop", "ref": "gate:42:3f2a9c01de", "actor": "Ada",
  "createdAt": "2026-10-16T09:12:03Z",
  "data": { "op": "resolved", "kind": "gate", "taskId": 42, "gate": "Approve copy" } }
data fieldMeaning
opraised (it waits now) · resolved (answered: a gate ticked, a todo done, a draft given its verdict, a question answered) · withdrawn (it stopped waiting unanswered: removed, closed, deleted, retracted, its board archived) · changed (it still waits; its ask or the person it waits on moved)
kindgate · todo · intake · plan · ask — the same kinds as the read's rows
taskId, gateThe task a gate, todo or draft lives on, and a gate's key for POST /api/v1/tasks/{id}/gates/resolve
verdictA draft's verdict: accepted · declined · merged
board, question, countAn ask's board, the question that moved (0 when the whole board was archived or restored) and how many open questions the board's row still holds — 0 means the row is gone

Only gate:<task>:<handle> and todo:<task> name one row: a client that holds it can settle it on resolved or withdrawn. ask:<board> is the board's folded row, gone only when count is 0; intake:<draft> names a draft (its family's row only when it heads one) and plan:<project>:<goal>:<day> the goal-day a move belongs to (a full one continues on :<part> cards), so both always re-read. So do raised, changed and any ref the client does not hold: Needs you alone knows whom a decision waits on and what a folded row still holds. A frame is visible to whoever may see what it is about: task decisions to the workspace, a personal board's asks to its owner. A todo falling due with the clock is not a write, so it is not a frame — Needs you finds it on the next read.

Recorder reports and policy

The embeddable report widget and the browser recorder talk to two small families the QA pages describe from the tester's side.

EndpointWhat it does
POST /api/v1/reports · GET /api/v1/reportsA report from the <vitrinka-report> widget is filed at once when the host app signed its reporter, and held quarantined otherwise; rrweb chunks and their events stream in under it, submit is its last call, and the review queue lists the held ones for admins.
POST /api/v1/reports/{id}/approve · reject · redact · promoteApproval materializes the capture as a session and files the report as a task in the Inbox. Promotion creates or links an external ticket; approval requests it automatically only when the project enables auto-ticket. Redaction removes captured data after submission.
GET /api/v1/report-configProject configuration readable by a publishable widget key. The key can also submit reports and their capture uploads; it cannot read the review queue.
GET · PUT /api/v1/recorder/policyThe redaction policy both the capture clients and the server's ingest backstop apply — redaction.
GET · PATCH /api/v1/recorder/meA recorder token's own record, reachable only with a vkr_ token: kind (linked or key), the workspace, the linked person (null for an admin key), the key's project, the device label or key name, and the recorder HUD's prefs (size and verbose) stored on the person's account. A PATCH merges {"prefs":{…}}; an admin key gets 409, its preferences stay on the device.

Identity, licence and version

EndpointWhat it does
GET /api/v1/whoamiWho the credential is, in which workspace, with which role.
GET /api/v1/versionThe server's version — an inbound beacon clients poll; the server never phones home.
GET /api/v1/licenseWhether the deployment is licensed, and its status — active, grace or expired, empty when unlicensed. Only a signed-in browser session also gets the customer, tier and expiry; a token never does. It answers even while an expired licence pauses writes, and every response of a licensed deployment carries the status in X-License-Status — licensing.
GET /api/v1/auditThe append-only audit log, searchable by admins — audit.

The AI backend proxy

/api/v1/eve/{path} forwards requests from project, task and board conversations to the configured AI backend. Its interface is the AI backend protocol, docs/ai-backend-protocol.md in the server's source. Workspace backend settings override AI_BACKEND_URL and AI_BACKEND_TOKEN. Mutating calls to the managed backend require AI credits; reads and workspace-owned compute do not.

OAuth for MCP hosts

Hosts connect to /mcp (or /w/<workspace>/mcp) as public OAuth 2.1 clients: authorization code with mandatory PKCE, discovered from /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource (also with the /mcp and /w/<slug>/mcp suffixes); registration by client-ID metadata document or POST /oauth/register (the latter alone under AIR_GAP=1); the flow is GET and POST /oauth/authorize and POST /oauth/token, with rotating refresh tokens. A grant is scoped read or read write and reaches the workspaces ticked on consent — by default every team workspace of your organisations, later ones included; Personal only when ticked — editable later in Settings → Connected apps without a new sign-in. Its default workspace takes calls that name none (an MCP call naming a project that one other reached workspace alone knows lands there; one several know is refused with each spelling); every other reached workspace is one header or <workspace>/<project> away; a workspace outside the reach answers 403 workspace_not_connected with a manage_url. POST /oauth/revoke (RFC 7009) retires a connection; users disconnect connections and admins remove them from their workspace in their connection lists — MCP for any client.

On this page