Lolly
啟動應用程式

API surface

備註: Lolly Work

This guide covers the optional organisation service. Configuration, console and API paths refer to your Work instance. The packaged console keeps its release-matched copy of this guide.

Every route the console and the CLI use - they share one API, so the two surfaces stay in parity by construction. Errors are { error: { code, message } } with an honest HTTP status.

"Action" is the RBAC action the caller must hold (permissions); member means any signed-in member; public means no session needed.

Health and metrics

RouteActionNotes
GET /healthzpublic{ ok, name, accessMode, appUrl? } - liveness
GET /readyzpublic{ ok, store }, 503 while the store cannot answer - readiness
GET /metricstokenPrometheus; loopback-only unless LW_METRICS_TOKEN is set
GET /api/v1/alertstelemetry.view{ alerts: [{ id, kind, level, month, value, threshold, title, detail, href }] }: alerts the server raises for administrators, shown as the console banner. Today only the blob offload trigger

Instance manifest and the connect surface

RouteActionNotes
GET /api/v1/instancepublicthe card a fresh shell reads before sign-in
GET /connect/pack.lollyopen: public · else memberthe hosted signed instance pack; 404 when none is hosted; ETag + If-None-Match → 304
GET /api/v1/instance-packfleet.viewthe hosted pack's metadata (name, version, signed, checksum), or null
PUT /api/v1/instance-packinstance.config (owner)host a pack cut by the OSS builder; refused unless its instance base is this deployment
DELETE /api/v1/instance-packinstance.config (owner)stop hosting

The manifest is what a downloaded app learns about this deployment before anyone signs in: { name, accessMode, provider, providerName, loginPath, engineVersion, capabilities, brand, connect? }. engineVersion is the vendored engine pin this deploy serves tools against; capabilities identifies the surfaces the server ships (catalog, collab, submit, scim); connect.packUrl appears exactly while a pack is hosted. It carries no secrets and no user data, and shares the auth rate-limit bucket. Any origin may read it (Access-Control-Allow-Origin: *, with OPTIONS answering the preflight), because a client that adds this deployment's design system by URL fetches this card from its own page before anyone has signed in. The pack itself is never built here - the OSS repo's build-instance-pack.ts owns the signed format, and this instance is where the finished pack publishes to. See "Connecting apps to this instance" in operations for the connect story.

brand is the design system this deployment hosts, for a client that adds one by URL and keeps it on the device for offline use (OSS plans/186):

"brand": {
  "profile": "suse",
  "label": "SUSE tokens",
  "version": "1.2.0",
  "checksum": "sha256-QOn2…",
  "locked": true,
  "packUrl": "https://brand.example/connect/pack.lolly"
}

Every field comes from the mounted pack itself, never from a second copy here. profile is the active brand profile on a profile-aware pack (the one GET /api/v1/brand/profiles lists) and null on a single-brand one; label is the tokens asset's name; checksum is that asset's own integrity checksum, which is what a client compares to ask "has the brand here changed since I copied it" without downloading anything; locked mirrors the tokens asset's brandLock, so a client knows the brand is authoritative and must not offer to customise it; version is the hosted pack's version when one is hosted; packUrl repeats connect.packUrl so one block answers both what is here and where to download the pack. The whole block is null when the pack ships no tokens asset, and it changes the moment an admin switches brand profile. It states no colours and no font files: those stay behind GET /api/brand.

The pack download is conditional. GET /connect/pack.lolly sends a strong ETag (the hosted pack's checksum, the same one GET /api/v1/instance-pack reports) with cache-control: private, no-cache, and answers 304 to an If-None-Match that matches, so a client re-checks a pack it already holds for the price of one header. The access gate is unchanged and runs first: on a gated instance an unauthenticated conditional request gets 401 and no tag.

Cross-origin reads of the pack follow that same gate. An open instance sends Access-Control-Allow-Origin: * and Access-Control-Expose-Headers: ETag (a browser hides the tag from script otherwise, and the tag is what the client came for), and answers the OPTIONS preflight a conditional GET triggers with Allow-Methods: GET, Allow-Headers: If-None-Match and a day of Max-Age. A gated instance sends no CORS header at all, on the download or the preflight: a page on another origin cannot present the session cookie, and a wildcard with credentials is refused by browsers, so the answer there is to sign in on the instance and export the pack, or connect from the desktop app.

Auth

RouteActionNotes
GET /api/auth/configpublicwhat the sign-in screen needs (mode, IdP display name, and providers: [{ id, name, kind, loginPath }] with kind oidc, github or password), plus instanceName (instance.name), inviteOnly (true while idp.admission is set) and joinRequests (policy.requests.join: the refusal page offers Ask to join)
GET /api/auth/loginpublicstarts sign-in with PKCE: OIDC, or GitHub OAuth 2.0 for ?idp= naming a kind: "github" entry; for a kind: "password" entry, the email and password form. With several entries and no ?idp=, the chooser; 404 NO_IDP with no sign-in configured
POST /api/auth/password/loginpublicthe form (email, password, returnTo, csrf) answers 303 to returnTo with lw_session, or the form again with 400 and one message for an unknown email, a wrong password and a locked address. A JSON body { email, password, returnTo? } answers { ok, returnTo } or 400 INVALID_CREDENTIALS. A refused admission is 403; an owner's account reached with a password not set from an owner's link is 403 OWNER_LINK_REQUIRED. 503 BUSY with Retry-After while too many password checks wait. See identity.md
GET /api/auth/password/set?token=…publicthe page a one-time link opens: the address, read-only, an optional name, and a new password twice. A used, expired or unknown link, or one its issuer may no longer issue, is a 410 page
POST /api/auth/password/setpublicthe form (token, name, password, confirm, returnTo, csrf): spends the link once, sets the password, ends the account's other sessions, signs the person in and answers 303 to returnTo. name is optional, 1 to 80 characters once trimmed, and becomes the account's display name. returnTo is set only by the invite page; a value that is not a path on this origin, or none, is /
GET /api/auth/callbackpublicverifies the id_token (OIDC) or reads the GitHub profile and emails, then mints lw_session. A failure a browser sees is an HTML page with a way to start again; an API caller without Accept: text/html keeps the JSON error, except GitHub failures, which are always the page
GET /api/auth/link?idp=<id>&returnTo=<path>member (cookie)runs that IdP and links the identity it returns to the current user, then redirects to returnTo; no new session. An identity that belongs to someone else is a 409 HTML page. A password entry is not linked from here: a 400 HTML page says to ask an admin for a sign-in link, with a link back to returnTo. See identity.md
GET /api/v1/me/identitiesmemberthe person's linked sign-ins: { identities: [{ idp, subjectHash, displayName, email, emailVerified, linkedAt, lastLoginAt, canUnlink, unlinkBlocked? }], available: [{ id, name, kind, linkPath }] }
DELETE /api/v1/me/identities/:idp/:subjectHashmember204: the removal ends every session of the account, and the browser that asked gets a fresh session cookie in the same chain (the same authAt and sign-in time, cut at authAt + sessionMaxHours; see identity). No cookie when the request carried no session cookie of the person's or that chain has reached its cap, and the person signs in again. 409 ACCOUNT_SIGN_IN for the sign-in the account was created with, 409 LAST_SIGN_IN for the last one
POST /api/v1/me/revoke-sessionsmember (own session cookie)sign out on all devices: bumps the caller's own session epoch, so every session they hold ends on its next request, and clears lw_session and lw_guest on this browser. 204 with cache-control: no-store. A service token, a bearer caller or an agent acting for someone gets 401. Five calls an hour per person, then 429 RATE_LIMITED with retry-after: 3600. Audited user.sessions.revoked with { self: true }
GET /api/auth/dev?email=…publicdev provider only; 404 when dev.enabled is false
GET /api/auth/sessionmember/guestthe current principal
POST /api/auth/logoutanyclears both cookies
POST /api/v1/auth/devicepublicstart device sign-in: {deviceCode, userCode, verificationUri, interval, expiresIn}
POST /api/v1/auth/device/tokenpublicthe device's poll: {status} of pending/denied/expired, or approved + the session cookie (single read). The cookie continues the approving browser's chain and ends no later than its authAt + sessionMaxHours (sessionTtlHours from that sign-in while renewal is off); once that chain has ended the answer is denied, audited auth.denied with reason: 'session-max'
GET /activatemember (page)where a person types and confirms a device code - approval binds the approver's identity, so it lives here and nowhere else
GET /api/v1/auth/device/pendingfleet.viewpending codes, oldest first
POST /api/v1/auth/device/denyfleet.managerefuse a pending code from the console

The polled document

RouteActionNotes
GET /api/v1/org-configmemberthe one document a shell polls; ETag'd on policy version and inboxUnread, the same count GET /api/v1/inbox answers as unread for the same X-Lolly-Client (its shell and engine selectors apply to both)
GET /api/v1/org-config/preview?groups=a,bpolicy.editwhat a member in those groups would receive

Catalog

RouteActionNotes
GET /catalog/*per access modepack blobs, lifecycle-gated
GET /api/v1/catalog/assetsper access modeone page of the caller's feed: q, source, section, collection, tag, type, cursor, limit; returns { assets, total, nextCursor, facets, version } (large catalogs)
GET /api/v1/catalog/assets/*member; visible assetinspect source facts, organisation fields and text-index status without returning extracted words
GET /api/v1/catalog/searchcatalog.readlive fan-out to search-capable providers
PUT /api/v1/catalog/assets/<id>/metacatalog.editorg-defined field values and replacedBy on any asset the caller sees; name/description/tags on inst/* only
PUT /api/v1/catalog/text-index/<id>catalog.editexplicitly save reviewed text with sourceRevision, fileId and method; text: null clears; stale revisions return 409 (reviewed text)
GET /api/v1/catalog/assets/<id>/versionscatalog.readone instance asset's byte history, newest first, with the served version and the retention ceiling
PUT /api/v1/catalog/assets/<id>/headcatalog.editroll back: { "version": N } points the head at a version that already exists
DELETE /api/v1/catalog/assets/<id>/versions/<n>catalog.edit409 VERSION_IS_HEAD for the served version, 409 ASSET_HELD while a hold is set
GET /catalog/inst/<id>/<format>?v=Nper access modea prior version's bytes, through every gate the head answers to
GET /api/v1/catalog/fieldscatalog.readthe org's field definitions, plus a canEdit bit for honest UI
PUT/DELETE /api/v1/catalog/fields/<id>policy.editdefine or retire one field; the definitions also ride the governance document
GET /api/v1/catalog/tags[?provider=<id>]policy.edit or catalog.provider.manageevery label the catalog carries, counted per source, with the rules hiding each one
PUT /api/v1/catalog/tags/rulespolicy.edit (scope *), catalog.provider.manage (scope provider:<id>){ scope, hidden } replaces a hidden-tag list, { scope, hide, show } edits it; applied when the index is served
GET /api/v1/catalog/collectionscatalog.collection.managethe curator's view: every set as curated
GET/PUT/DELETE /api/v1/catalog/collections/<id>catalog.collection.managecreate, edit or remove one set; a PUT refuses any member the curator cannot see
GET /api/v1/catalog/lifecyclecatalog.expireall lifecycle rows
PUT /api/v1/catalog/lifecycle/*catalog.expireset/merge a row; revoke: true revokes
POST /api/v1/catalog/scan/*catalog.scanrecord a C2PA scan result for one asset (c2pa)
GET/POST /api/v1/injectables, DELETE …/:idcatalog.injectable.managethe assets/tools injected into member shells
GET /api/v1/brand/profiles, PUT /api/v1/brand/profilemember + catalog.read / brand.switchinspect sources; compatible profile selection
POST /api/v1/brand/changes/preview, POST /api/v1/brand/changesbrand.switch or instance.config, action dependentpreview impact; apply against the reviewed revision
GET /api/brand, /api/brand/logo/:variant, /api/brand/font/:filepublicbrand chrome only (tokens, wordmark, woff2) so the sign-in screen is on-brand

The design-system administration contract describes additive inventory fields, action permissions, replacement requirements and 409 STALE_PREVIEW. The public instance descriptor adds branding: { revision, sourceId } even when brand is null; authenticated org configuration carries the same pair. X-Lolly-Brand-Revision reports the request snapshot. A request carrying a different revision is rejected with 409 BRAND_REVISION_CHANGED so a render cannot combine source revisions.

Catalog submit

RouteActionNotes
POST /api/v1/catalog/submit?name=…catalog.submitraw bytes in the body; 201 for a new asset, 200 with duplicate: true for identical bytes
POST /api/v1/catalog/submit?name=…&projectId=…&fileId=…&checksum=…catalog.submit and current project read accessempty body; admits the verified ready project original through the same scan, quota and review pipeline; project files must be enabled on a durable store; service tokens are refused
POST /api/v1/catalog/submit?name=…&uploadId=…&checksum=…catalog.submit (catalog.edit with assetId)empty body; submits the caller's own finished catalog-submission upload through the same scan, quota and review pipeline, then removes the upload. 404 for any upload the caller cannot name; 413 when it is larger than policy.submit.maxBytes
`POST /api/v1/catalog/submit?type=template\user-tool`catalog.submita template or user tool as JSON; toolId= identifies the tool when the body does not; 422 INVALID_SUBMISSION when the JSON or its tool is not one this pack can serve; clientRef= is echoed on the submission
POST /api/v1/catalog/submit?assetId=inst/…&note=…catalog.editthe same pipeline, landing as the next VERSION of an existing asset; groups/type/tags/description are refused here and belong to …/meta
GET /api/v1/catalog/submissionscatalog.readthe caller's own submissions plus the ones open on a step their groups may act on
GET /api/v1/catalog/submissions/:id/bytescatalog.readpreview before publication - submitter and reviewer only
PATCH /api/v1/catalog/submissions/:idcatalog.readcorrect a pending submission's name/type/tags/description and its org fields, and (with catalog.collection.manage) the collectionId it joins on approval; 409 once it has settled
POST /api/v1/catalog/submissions/:id/actmember (the approvals engine gates it)approve publishes, reject returns with the comment

Project-source intake requires the observed whole-file SHA-256. Work verifies every stored part and the complete file, then checks source access again after scanning. The source file stays unchanged. groups= selects library exposure independently of project membership; omitted groups mean every workspace member. Only groups the submitter belongs to are accepted. type, toolId and assetId cannot be combined with a project source. Source identifiers are retained privately in the submission and audit event, never in the public catalog entry.

Retries for the same person, project file, checksum and audience return the existing live or pending submission. Concurrent admissions of the same source are serialized by a transaction advisory lock in Postgres; contention returns 409 SUBMISSION_BUSY. A pending record left without an approval by an interrupted admission returns 503 SUBMISSION_INCOMPLETE for administrator inspection instead of creating another asset or claiming review has started. A changed or inaccessible source returns 404 NOT_FOUND; verification failures return 422 SOURCE_UNAVAILABLE; access lost during scanning returns 403 SOURCE_CHANGED.

Refusals: 413 PAYLOAD_TOO_LARGE over policy.submit.maxBytes, 409 QUOTA_EXCEEDED, 422 SCAN_REJECTED when the pre-store hook vetoes, 502 SCAN_UNAVAILABLE when it cannot answer and onError is reject, 503 SUBMIT_CHAIN_MISSING when policy.submit.chain names a chain the instance does not have. The preview route answers 410 ASSET_EXPIRED once the published asset's lifecycle stops it, like every other surface that hands out bytes. See catalog.

Catalog providers

RouteAction
GET /api/v1/catalog/providers, GET …/setup, GET …/:id, GET …/:id/health, GET …/:id/driftcatalog.provider.read
POST /api/v1/catalog/providers, PUT …/:id, DELETE …/:id, POST …/preview, POST …/:id/setup-preview, POST …/:id/sync, POST …/:id/materialize, POST …/:id/import (one asset)catalog.provider.manage
PUT/DELETE …/:id/credential, POST …/:id/oauth/start, POST …/:id/enable, POST …/:id/disable, POST …/:id/cutovercatalog.provider.credential (owner)
POST …/:id/publishcatalog.provider.publish (owner)

Config-managed providers reject mutations with 409 CONFIG_MANAGED. Cutover returns 422 ORIGINALS_UNAVAILABLE for a source that explicitly provides renditions without protected originals. The refusal leaves identities, aliases and activation state unchanged.

GET …/setup returns versioned public typed-form descriptors (WebDAV and Google Drive), credential sealing availability and the installed browser-consent redirect URI/scope, with no secrets. Provider wire records include guidedSetupAvailable; advanced settings outside the guided subset do not receive the guided resume action. POST …/preview with setupVersion: 1 validates that guided configuration and tests a bounded paged listing plus one streamed original. The response includes pages, scanned, sampleTotal, truncated, exposure and availability exclusions, mapper notes and original: {ok,bytes?,sha256?,contentType?,detail?}. No source, file or credential is persisted. The guided limits are five pages, 1,000 distinct files, 2 MiB per XML/Drive metadata response, 32 MiB per original and 30 seconds overall. Legacy preview and exclusive shape: true diagnostics retain their existing contracts. Creation and PUT …/:id accept setupVersion: 1 to validate the guided subset; guided updates require a disabled source. POST …/:id/setup-preview tests a guided DB-managed Google Drive source using its sealed credential, without returning the credential. Its response adds revision, a digest of the configuration and credential identity. POST …/:id/enable accepts {setupRevision} and refuses 409 SETUP_CHANGED if it no longer matches, including changes during the health check. This optional guard preserves advanced activation contracts; creation, credential storage, sync and enabling retain their separate audited permissions.

POST …/:id/oauth/start accepts {clientId,clientSecret} for a disabled, guided DB-managed Google Drive source. It requires an owner browser session, credential sealing and a suitable instance URL. It returns {authorizeUrl} plus an encrypted HttpOnly/SameSite state cookie with a ten-minute lifetime, bound to the member session and source revision. Its client secret and PKCE verifier are sealed with a separate credential-key context; refresh tokens are never put in browser cookies or responses. Only the latest consent in a browser is pending. GET /api/auth/provider-oauth/callback checks state, session, current permission and source revision, exchanges the code on Google's fixed endpoint with PKCE, checks offline read-only scope and folder access, then seals the credential. JSON replies are capped at 64 KiB with a 20-second aggregate exchange deadline. Failed/declined consent preserves the old grant. The callback clears the state cookie and redirects to /admin#/providers?setup=<id>&oauth=… with a safe outcome (connected, denied, expired, changed, failed); codes, vendor error descriptions and credentials are not reflected. Reconnecting requires a disabled source. Google Drive is the only browser-consent kind in this version.

Outbound delivery

RouteActionNotes
GET /api/v1/destinationsauthenticated; results filtered by delivery.create per targetsafe fixed-target descriptors visible to this caller; never endpoints, bucket names, prefixes or credential refs
POST /api/v1/destinations/:id/deliveries?name=…&format=…delivery.create on destination:<id>verified Lolly export as the raw body; 201 after immediate delivery, or 202 awaiting-approval when the target binds a chain
POST /api/v1/destinations/:id/deliveries?projectId=…&fileId=…&checksum=…signed-in person + current project viewer access + delivery.createempty body; verified shared original, only with explicit projectFiles on a DAM destination
POST /api/v1/jobs/:id/deliveriesjob owner + delivery.create on the body’s destinationIddeliver a completed render job’s retained output by reference; JSON {destinationId,name,format?}
GET /api/v1/deliveriesauthenticatedcaller's own delivery history remains readable after permission loss
GET /api/v1/deliveries/:idauthenticatedcaller's own delivery receipt; another principal receives 404
POST /api/v1/deliveries/:id/retrydelivery.create on the destinationretry failed work from its immutable staged bytes; refuses a changed destination

Both create routes accept Idempotency-Key; repeating the same delivery returns its existing record without another provider write, while reuse for different bytes/metadata returns 409 IDEMPOTENCY_KEY_REUSED. The raw body, or the referenced job output, must carry Lolly's C2PA export assertion. See outbound delivery.

Policy and governance

RouteAction
GET /api/v1/policy/toolspolicy.edit
PUT /api/v1/policy/overlays/:toolIdpolicy.edit
GET /api/v1/policy/flags, PUT /api/v1/policy/flags/:flagIdpolicy.edit
GET /api/v1/grantsgrant.edit
POST /api/v1/grants, DELETE /api/v1/grantsgrant.edit + owner for owner-only actions
GET /api/v1/config/exportpolicy.edit
POST /api/v1/config/apply?dryRun=1&prune=1policy.edit (+ owner for owner-only grants)
GET /api/v1/chains, PUT /api/v1/chains/:idmember / policy.edit

People and groups

RouteAction
GET /api/v1/users, GET /api/v1/users/:idadmin/owner role
POST /api/v1/users/:id/revoke-sessionsgrant.edit - sign-out-everywhere: bumps the user's session epoch, every prior cookie and token fails its next request
DELETE /api/v1/users/:idinstance.config (owner) - erasure: deletes the row + de-attributes telemetry; 409 while they own unarchived projects, and 409 ERASE_UPLOADS_REMAIN when some of their uploads could not be removed first (run it again)
POST /api/v1/retention/runinstance.config (owner) - apply the stated retention policy now, and remove expired unfinished project-file uploads (projectFilesSwept) and expired scoped uploads (uploadsSwept)
GET/POST /api/v1/groups, DELETE /api/v1/groups/:namegrant.edit
PUT /api/v1/users/:id/local-groupsgrant.edit
POST /api/v1/users/:id/disabledgrant.edit
GET /api/v1/users/:id/identitiesadmin/owner rolethat person's linked sign-ins, the same rows as /api/v1/me/identities. With a password entry configured, also password: { set, email, lockedUntil? }: whether the person has a password, the address a new link would be for, and until when it is locked
DELETE /api/v1/users/:id/identities/:idp/:subjectHashgrant.edit204, and every session of that person ends; the same two refusals; an owner's sign-ins are owner-only. Removing a password sign-in deletes the password (also from DELETE /api/v1/me/identities/...)
POST /api/v1/users/:id/password/unlockgrant.edit204: lifts a lock that wrong passwords set, keeping the password. 404 NO_PASSWORD when the person has none; an owner's is owner-only

Invitations

Who may sign in when idp.admission is set, one email address at a time. See identity for how sign-in uses an invitation.

RouteActionNotes
GET /api/v1/invitationsuser.invite (admin, owner)every invitation, newest first, revoked ones included; plus signInUrl, admission: { policy, invitations }, and what an invite message needs: providers (the names of the sign-ins on offer), passwordSignIn (a password entry is configured), passwordDomains (policy.invites.passwordDomains) and inviteNote (instance.inviteNote, or null)
POST /api/v1/invitationsuser.invitebody { emails: string[], groups?: string[], expiresAt?, passwordSetup?: boolean }; 201 when anything was created, 200 otherwise. With groups, an address that already belongs to an account joins them now (status: "applied") or is left alone (status: "refused", reason: self, account-disabled, owner-only). Without groups, such an address is status: "already" and no invitation is written. A new address follows policy.invites: reason invites-not-allowed or domain-not-allowed. Each invitation in the answer carries its link and expiresAt
POST /api/v1/invitations/:id/linkuser.inviteNew link: 200 { invitation } with a new link. Every link copied earlier for this invitation, from the console or a project, stops working, and openedAt is cleared. Refused for an accepted or revoked invitation; 10 a day per invitation, then 429 RATE_LIMITED
POST /api/v1/invitations/:id/reinviteuser.invite, with the same group controls as POSTInvite again, for an expired or revoked invitation: body { expiresAt? }; 201 { invitation }, a new invitation with the same address, groups and projects and a new link. 409 ACTIVE_INVITATION { invitation } when the address already has a live one
DELETE /api/v1/invitations/:iduser.inviterevoke; 404 when the id is unknown or already revoked
POST /api/v1/admin/password-linksadmin or owner session with user.invitebody `{ email, purpose: "setup" \"reset" }; 201 { url, expiresAt }: a one-time link to set a password, valid 7 days, which cancels the address's earlier unused links. 404 NO_PASSWORD_SIGN_IN without a password entry, 409 NOT_ADMITTED for an address admission refuses, 409 ACCOUNT_DISABLED, 403 OWNER_ONLY` unless an owner asks for an owner's address or for an account that already signs in another way (an admin's own account excepted). The same rules are asked again when the link is used. Service tokens are refused

An invitation reads:

{
  id, email, groups, invitedBy, inviter: { name } | null,
  createdAt, expiresAt, acceptedAt, acceptedUserId, acceptedUser: { name, email } | null, revokedAt,
  status: 'pending' | 'accepted' | 'expired' | 'revoked',
  projects: [{ projectId, name, role, invitedBy: { name } | null }],
  createdVia: 'console' | 'project' | 'request',
  link,                      // the workspace invite link; only while pending, else null
  linkVersion, openedAt,     // openedAt: the first sign-in started from a link, or null
  passwordSetup,             // the link may set a password for the address
  password: 'none' | 'set',  // whether the address already has a password
}

Absent dates are null. projects are the projects the person joins on acceptance (empty for an invitation that only admits and groups), each with the name of the person who added that project. createdVia is console (this route), project (a project invite) or request (an approved request). inviter and acceptedUser carry names for the console; acceptedUser.email is the address the account signed in with, which can differ from the invited one (a GitHub account with the invited address as a secondary one). POST answers { invitations: [...], signInUrl, admission }: an invitation carries created: boolean, and an address handled without one reads { email, status: "applied" | "refused" | "already", reason?, userIds, groups, created: false }.

Nothing is emailed. Send each person their link, which opens the invite page for that invitation. signInUrl is instance.baseUrl. Audit actions: invite.create (with via: console, project, request or reinvite, plus from for an invitation made again and passwordSetup), invite.link (a new link), invite.open (the first sign-in started from a link), invite.wrong-account, invite.revoke and invite.accept (with via: sign-in, join or link).

Every pending invitation has a personal link, <baseUrl>/l/invite/<token>, and each project on it has a link of its own. The token is signed with LW_LINK_SECRET (the previous key still verifies during a rotation) over the invitation id, the project id (none for the workspace link) and the invitation's link version. It is never stored. A token identifies an invitation and never admits anyone: the person still signs in, with the invited address. New link raises the version, so every earlier link stops working. See identity for the pages and the sign-in they start.

Pages and forms. Server-rendered HTML in English, with no script.

RouteWhoNotes
GET /l/invite/:tokenpublic; reads the session cookie when there is onethe invite page. 200 while the invitation is pending or accepted, 410 once it has ended. Unknown, malformed, revoked and replaced tokens all get the same 410 "This link no longer works" page. Reading it changes nothing, so a chat preview or a mail scanner leaves no trace. link rate-limit bucket
POST /api/auth/invitepublic; join needs a sessionthe invite page's forms: token, csrf, action (start, password or join), idp (for start) and prompt (select_account, optional). start records the first opening, then answers 302 to that IdP, or 200 with the email and password form. password answers 200 with the set-password form when this invitation may set one, else the invite page again with an error line. join accepts the invitation for a signed-in account that holds the address and answers 303 to the project, or 200 with the wrong-account page. A dead or ended token gets its 410 page. auth rate-limit bucket
POST /api/auth/requestpublic, with a valid ask tokenthe forms on the refusal and wrong-account pages: ask, csrf, action (join, switch or withdraw) and note (optional, at most 280 characters). 200 "Request sent" whether the request was created, was already open or was held by a cap; withdraw answers "Request withdrawn". An expired ask token or form is a 403 "This page expired" page. auth rate-limit bucket

Every form carries the lw_form double-submit nonce (a cookie on Path=/api/auth) and passes the Origin check. The pages are sent with cache-control: no-store, x-content-type-options: nosniff, referrer-policy: strict-origin, x-robots-tag: noindex, nofollow and content-security-policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'. The ask token is minted only on a page the server renders right after it verified a sign-in (the refusal and wrong-account pages). It lives 30 minutes, travels in a hidden form field, and carries the verified address, so the address on a request is never typed.

Requests. JSON routes with the session cookie. Service tokens are refused.

RouteWhoNotes
POST /api/v1/projects/:id/access-requestsmember (no guest)Ask for access, or Ask to edit: body `{ role: "viewer" \"editor", note? }. Always 202 { "ok": true }: an unknown or archived project, access the caller already has, a request already open and requests switched off all get the same answer, so filing reveals nothing. A note over 280 characters is 400 INVALID_INPUT. After 20 requests in a day, 429 RATE_LIMITED with Retry-After`
POST /api/v1/sessions/:id/access-requestsmember (no guest)the same for a session link: the server finds the session's project and records the session the request came from. Shares the daily quota
GET /api/v1/access-requests/mine?projectId=… or ?sessionId=…member{ requests: MyRequest[] }: the caller's own requests for that target, open or answered in the last 30 days, newest first
POST /api/v1/access-requests/:id/withdrawthe requesterbody {}; 200 { request: MyRequest }, or 404 for anyone else
`GET /api/v1/access-requests?status=open\answered&since=…`member{ requests: RequestView[] }: the requests the caller may answer now, and nothing for someone who answers none
POST /api/v1/access-requests/:id/approvean approver, checked again nowbody { role? } (project requests; the role asked for when absent). 200 { request, outcome, invitation?, link?, message? }, with outcome one of added, already, invited or moved. message.text is a ready-to-send line for an approved join, for example "You can now sign in to lolly.ing. Open https://lolly.ing and sign in as sam.k@gmail.com with GitHub."
POST /api/v1/access-requests/:id/declinean approverbody {}; 200 { request }
type MyRequest = { id, status, role, createdAt, answeredAt, answerRole };
type RequestView = {
  id, kind: 'project' | 'join' | 'switch',
  status: 'open' | 'approved' | 'declined' | 'withdrawn' | 'superseded' | 'expired',
  email, name, provider,     // provider: the display name of the sign-in that proved the address
  note, role, currentRole,   // currentRole: the requester's access when they asked
  project: { id, name } | null, session: { id, name } | null,
  invitation: { id, maskedEmail, inviter } | null,   // switch requests only
  createdAt, expiresAt, answeredAt, answeredBy: { name } | null, answerRole,
};

Who may answer, what each approval does, and how requests close are in sharing and identity. An approver is checked again on every list, approve and decline, against the project stored on the request, never an id the client sends. Exactly one answer wins: approve claims the request first, then acts. If the action then fails on a database error, the answer is 500, the request stays approved, the audit row records effect: "failed", and a manager adds the person by hand.

CodeStatusMeaning
ALREADY_ANSWERED409someone answered first; the body's request says who and how
FORBIDDEN403the caller may not answer this request now, for example a manager since demoted
REQUESTER_UNAVAILABLE409the requester's account is disabled; the request closes as expired
PROJECT_ARCHIVED409the project was archived; the request closes as expired
INVITATION_ENDED409a switch request whose invitation is no longer pending; the request closes as expired

Audit actions: access.request, access.request.held (a cap held it; reason per-email, workspace-cap or per-invitation), access.approve, access.decline, access.withdraw and access.supersede, each with subject request:<id> (a held request stores nothing, so its subject is request). A note is recorded only as its length (noteChars), never its text, and no payload holds a token.

Service tokens

RouteActionNotes
POST /api/v1/tokenstoken.manage (owner)mint; the secret (lwt_…) appears in this response and never again
GET /api/v1/tokenstoken.manage (owner)list with last-used and revocation state - never secrets
DELETE /api/v1/tokens/:idtoken.manage (owner)revoke; the very next use gets 401

A minted token rides Authorization: Bearer lwt_… and resolves to a synthetic principal carrying the token's role (no groups) - RBAC, grants and audit see it like any member (user:svc_<id> actor). It works on the action-gated surface (fleet, providers, governance export/apply, audit, telemetry summary); approvals, submit and collab refuse it - those flows mean "a person decided". See identity.

SCIM provisioning

Admin (cookie, owner-only) mints the bearer; the protocol half is what the IdP calls with the bearer. See identity.

RouteAuth
POST/GET /api/v1/scim/tokens, DELETE /api/v1/scim/tokens/:idscim.manage (owner)
GET /scim/v2/ServiceProviderConfigSCIM bearer
GET/POST /scim/v2/Users, GET/PATCH/DELETE /scim/v2/Users/:idSCIM bearer
GET/POST /scim/v2/Groups, GET/PATCH/DELETE /scim/v2/Groups/:nameSCIM bearer

GET /scim/v2/Users?filter=userName eq "…" (and externalId eq "…") is the existence check an IdP runs before create. active=false on a User PATCH (or a DELETE) deprovisions: disable + session-epoch bump. Group membership maps to each user's local groups.

Approvals and inbox

RouteAction
GET/POST /api/v1/approvalsmember
GET /api/v1/approvals/approversmember
POST /api/v1/approvals/:id/actapproval.act
POST /api/v1/approvals/:id/withdrawmember (submitter)
GET /api/v1/inboxmember - { messages, unread } with ETag: "ib-<16 hex>" and cache-control: private, no-cache; a matching If-None-Match answers 304, so a shell can check often for one header. messages holds the caller's messages and comment notices; unread counts both, and the tag moves when either changes
POST /api/v1/inbox/:id/ackmember - dismisses the message for the caller. For a comment notice (cn_…) it deletes the caller's own notice row and nothing else, so the next reply in that thread shows a notice again
GET/POST /api/v1/messagesmessage.send

Message targeting is groups × shell selectors × engine-version range. Besides the console's messages, the server writes approval, expiry, collab and share messages, request (someone asks for access and you may answer) and notice (an answer to your request, an accepted invitation, a welcome, an invitation entry that no longer works). data.kind identifies which, and data.at is when it happened; the kinds are listed in sharing.

The inbox hides a message whose subject the caller can no longer reach: a project share or a live-collab invite once they cannot see the project, an invite also once its session is deleted, and an access request for a project once they no longer manage the project. Hidden is not deleted: the message comes back if the access does.

Comment notices (plan 76 milestone 4) appear in the same list. There is one per person per thread, updated as replies arrive, and it is built when the inbox is read:

{ "id": "cn_<24 hex>", "kind": "comment", "severity": "info",
  "title": "Ana mentioned you in Spring poster",
  "body": "<the first 140 characters of the newest message someone else wrote and nobody deleted>",
  "cta": { "label": "Open thread", "url": "<appUrl>/#/team/<sessionId>?thread=<threadId>" },
  "data": { "kind": "comment-mention", "sessionId": "...", "projectId": "...", "threadId": "...",
            "actorName": "Ana", "label": "Spring poster", "count": "1", "at": "<iso>" },
  "dismissible": true }

data.kind is comment-mention or comment-reply. A reply notice that counts several replies is titled "New replies in Spring poster: 3". actorName is the person's name, never an address, and Member when the account is gone. With no message by someone else left, body is "This comment is no longer available." appUrl is instance.appUrl, or empty when the app is served from this origin. The text is English; a client should build its own title from data and its own link from data.sessionId and data.threadId.

Each notice is checked again on every read with the rule the comments routes use: the caller can still open the session, session.view and comment.view hold, and policy.comments.enabled and policy.comments.notices are not false. A notice that fails a grant or the policy is hidden and kept until it is 30 days old; after that the next read of the inbox (or of org-config) removes it, as does the next notice written for the person. One whose session is deleted, or whose project the caller can no longer reach, is deleted. Notices are never emailed today; see policy.comments under Canvas comments.

RouteAction
POST /api/v1/linkslink.create (link.create-guest for guest-edit)
GET /api/v1/links[?all=1]member (own links; all=1 needs the admin view)
POST /api/v1/links/:id/revokeown link, or link.revoke
GET /l/:id?s=…[&name=…]public (the signature is the authorization); a password link also needs its cookie. A browser opening a guest link gets its landing page, which admits nobody
POST /l/:id?s=…[&name=…]public: checks a password link's password and sets its cookie; the guest landing's form joins here (name, password) and is answered with 303 into the app
GET /l/:id?s=…&zip=1, …&asset=<id>[&dl=1]public - a collection link's zip-all, and one member of that set (an id it does not name is 404 NOT_IN_COLLECTION)
GET /render/<toolId>.<format>export.server, or a guest scoped to that tool

A password link's password goes in the body of POST /l/:id?s=…, as the form field password or as JSON {"password": "…"}. The answer sets an HttpOnly cookie, lw_link_<id>, with Path=/l/<id>, lasting 12 hours at most and never past the link's expiry. A form gets a 303 back to the link and JSON gets 200 {"ok": true}. A wrong password is 401 PASSWORD_INCORRECT; ten wrong passwords from one address within 15 minutes lock the link for that address (429 PASSWORD_LOCKED with Retry-After). A browser that opens a password link without the cookie gets a page with that form. Password links answer private, no-store, because the cookie, not the URL, decides who reads them. &pw=… on the GET is deprecated and still works for one more release: the answer carries a Deprecation header and sets the same cookie, and the server logs one warning per link. See Password links.

For worker-backed renders, asset inputs use the same URL encoding as the Lolly shell. A user/team/<fileId> reference is resolved to that project's file endpoint only when the submitter can read the project and the file belongs to the render's scoped read ticket. The worker receives no general session credentials. Private files outside that scope remain inaccessible; inline baked assets keep their original bytes.

Recoverable render resources

POST /api/v1/renders submits an authenticated, persistent single-tool request. List/get its state, retrieve its verified output, cancel it, or create a retry resource through the same API. The standalone server recovers expired leases; function-only hosts refuse new submissions. See Recoverable renders for the request contract, routes, limits and deployment requirements.

New durable outputs expose a partial execution receipt at GET /api/v1/renders/:id/evidence. It records loaded source hashes, prepared context, observed asset byte digests and the output digest under the same lease. The receipt describes its coverage gaps; it is not a complete dependency lock. The same download includes the output's render identity, which binds the typed request to the resolved tool source, engine version, sources and effective policy.

Render and batch requests accept verification: "output-v1", or verification: { profile: "output-v1", widthPx: 1200, heightPx: 630 }. These assertions require final-file readback before publication. Failed required checks retain error.inspection and publish no output. The verification profile lists supported formats and measurement limits; this is not design approval.

POST /api/v1/render-batches creates a parent and up to 200 independent child renders atomically. Read its ordered rows and output receipts, download a JSON manifest, cancel unfinished rows or retry a terminal batch while retaining its successful children. The batch API is separate from the existing /api/v1/batch job/ZIP contract.

Projects and sessions

RouteAction
GET /api/v1/projectsmember: every project they can open (admins all), including projects shared with everyone on the instance. Archived projects are left out unless you pass ?archived=1. Rows carry myRole, updatedAt, updatedByName, via (why they can open it: owner, member, group, custom-group, everyone or admin), audience (restricted or instance), and their own listed (pinned or hidden) and lastOpenedAt. A shell lists a project as the person's own when via is a relationship, or when they pinned it or opened it recently and did not hide it; the rest is found by browsing
POST /api/v1/projects/:id/openedviewer - records that the caller opened the project, for their own recent list; 200 { lastOpenedAt, listed? }. Visible to nobody else and never used for who viewed what
PUT /api/v1/projects/:id/listingviewer - body `{ listed: "pinned" \"hidden" \null }`: keep the project in the caller's own list, keep it out, or follow the default
POST /api/v1/projectsproject.create
PATCH /api/v1/projects/:idmanager of the project or project.manage - name, visibility, archive; ownerId (transfer to an enabled member; audited project.transfer) needs the owner or project.manage, and keeps the previous owner on the project as a manager (audited project.member.add with via: 'transfer') unless their account is disabled. A directory group taken out of visibility loses its sharing role too, so adding it back starts at editor
GET /api/v1/projects/:id/foldersviewer; { folders: [{ id, projectId, parentId, name, createdAt, createdBy, items: [{ kind, ref }] }] }
POST /api/v1/projects/:id/folderseditor and session.edit, project not archived; { name, parentId? }; 201 { folder }. An omitted or null parent creates a folder at the project root
PATCH /api/v1/projects/:id/folders/:folderIdeditor and session.edit; { name } renames a folder
PUT /api/v1/projects/:id/folders/items/:kind/:refeditor and session.edit; { folderId } moves a session or finished file into a folder in this project. Null returns it to the project root. Changes no document content or file bytes
GET/POST /api/v1/projects/:id/sessionsviewer / editor and session.create. List rows carry updatedByName
GET /api/v1/sessions/:id, GET …/revisionsviewer. The full session carries myRole, the caller's effective project role; workspace capabilities still apply
PUT /api/v1/sessions/:ideditor and session.edit. Session bodies (this and the POST above) may be up to 4 MiB; other routes take 512 KiB
DELETE /api/v1/sessions/:idsession.delete, editor, and the caller must be the session's creator or a manager of the project (its owner, a manager member, or a holder of project.manage; admins and owners hold it by default and a deny grant applies to them too)
GET /api/v1/projects/:id/membersviewer - { myRole, canTransfer, members: [{ userId, name, email?, role, via, addedAt, isMe? }], effective?, effectiveTruncated?, adminAccess?, invitations?, requests? }; email, effective, adminAccess, invitations and requests for managers only; isMe: true marks the caller's own row. via is owner or member. canTransfer says whether the caller may hand the project on, by the test PATCH applies to ownerId. effective lists the people who can open the project without a row on it: `[{ userId, name, role, via: group \admin, group?, isMe? }], no email, disabled accounts left out, sorted by name and capped at 200 (effectiveTruncated: true). Group rows cover only the groups the caller is in, unless the caller is an admin or owner. adminAccess is listed for an admin or owner caller, who also gets the admin rows, and note for any other manager, who gets no admin rows and sees group rows at the level the group gives; see permissions. invitations lists the pending invitations that carry the project and those that expired in the last 30 days: { id, email, role, createdAt, expiresAt?, status: pending \expired, openedAt?, invitedByName?, passwordSetup, link? }, where link is the invite link for this project's entry, while pending. requests lists the open requests for the project: { id, userId, name, email, role, currentRole, note?, createdAt, viaSession?: { id, name } }`
POST /api/v1/projects/:id/invitemanager - body { emails, role, passwordSetup? }; `200 { results: [{ email, status: added \invited \already \refused, reason?, invitationId?, link?, expiresAt? }], link, message }. A result's link is that address's own invite link for this project; the top-level link is <appUrl or baseUrl>/#/team/project/<id>. message: { workspace, inviter, providers, note? } is what Lolly needs to compose an invite message. passwordSetup is honoured only for an admin or owner with user.invite while a password entry is configured. New addresses follow policy.invites. A caller without user.invite may send 100 addresses an hour (429 RATE_LIMITED`). Reasons are listed in sharing
PATCH /api/v1/projects/:id/members/:userIdmanager - body { role }; 409 for the owner
PUT /api/v1/projects/:id/members/:userId/expirymanager - body { expiresAt }, an ISO time in the future or null to clear; within policy.sharing.maxGrantDays. A membership past its end date gives no access and stays listed so it can be extended; sharing with the person again, an accepted invitation or an approved request also restores it, with no end date. Editors who may share cannot set end dates: an end date a minute away is a removal, and removal is for managers. 409 for the owner
GET /api/v1/projects/:id/sharingviewer - `{ general: { audience: restricted \instance, role }, grants: [{ principal: { kind: group, name } \{ kind: custom-group, id, name, memberCount }, role, expiresAt? }], expiries, settings: { viewersCanComment, viewersCanExport, editorsCanShare }, policy: { audiences, instanceMaxRole, roles, customGroups, maxGrantDays? }, canManage }. expiries maps member ids to end dates, for managers only. viewersCanComment: false stops viewers creating comments and replies (403). viewersCanExport is advice to shells only: a viewer can render on their own device, so the server cannot enforce that restriction. Read by @lolly-tools/core/sharing-v1 readShareState`
PUT /api/v1/projects/:id/sharingmanager, or an editor when settings.editorsCanShare is on - any of { general, grants, settings }. grants replaces every group grant; a directory group leaves the project's visibility when it leaves grants. Only a manager gives or takes manager access or changes editorsCanShare. 403 AUDIENCE_NOT_ALLOWED / ROLE_NOT_ALLOWED / GROUPS_OFF follow policy.sharing and policy.invites.projectRoles; audited project.sharing
GET /api/v1/share-groupsmember - the groups the caller owns, manages or belongs to: `{ groups: [{ id, name, description?, memberCount, myRole: owner \manager \member }], enabled }`
POST /api/v1/share-groupsgroup.create - body `{ name, description?, add?: [userId \email] }`; the caller becomes the owner. People must be ones the caller already shares a project or group with, or an exact address of an enabled member. At most 100 owned groups and 500 people a group
GET /api/v1/share-groups/people?q=…member of the instance (not a guest or a service token), while policy.sharing.customGroups is on (403 GROUPS_OFF) - suggestions for adding people: { people: [{ id, name }], truncated }, drawn only from the caller's projects and groups (an exact address also matches). Never carries an address. At most 60 lookups a minute per person (429 RATE_LIMITED)
GET/PATCH/DELETE /api/v1/share-groups/:idmembers read { …summary, members: [{ id, name, role }] }; the owner and managers PATCH { name?, description?, add?, remove? }, the owner also { managers }, and any member may PATCH { remove: [own id] } to leave; the owner or an admin deletes. Audited share-group.*
DELETE /api/v1/projects/:id/members/:userIdmanager, or the member themselves; 204
DELETE /api/v1/projects/:id/invitations/:invitationIdmanager - takes the project off the invitation; a project-made invitation (createdVia: "project") left with no project is revoked, a console invitation never is; 204
POST /api/v1/projects/:id/invitations/:invitationId/linkmanager, for an invitation that carries this project - New link: 200 { link, expiresAt }. Every earlier link for the invitation stops working, the console's included. 10 a day per invitation, then 429 RATE_LIMITED
POST /api/v1/projects/:id/invitations/:invitationId/reinvitemanager who may invite new people - Invite again, for an expired entry: 200 with one result row, the same shape as invite. Counts toward the hourly invite quota
POST /api/v1/projects/:id/access-requestsmember - Ask for access or Ask to edit; see access requests
POST /api/v1/sessions/bulksession.edit and project.manage; only sessions in projects where the caller is an editor
GET /api/v1/collab/invitees?sessionId=…&q=…member with read access to the session
POST /api/v1/collab/invitescollab.edit (= session.edit)
GET /api/v1/collab/roomstelemetry.view - live room census for the console
GET/POST /api/v1/collab/nearbycollab.join - the nearby-discovery handover lane

Roles on a project (viewer, commenter, editor, manager, owner) are described in sharing. A commenter reads and comments like a viewer and never changes artwork. A viewer or commenter asked to write gets 403 READ_ONLY; someone who cannot see the project gets 403 FORBIDDEN.

Session writes are compare-and-set, never last-writer-wins. PUT requires the rev you read; a stale rev answers 409 CONFLICT with the full current server session in current, the client keeps the local edit and rebases - nothing is silently overwritten. bulk applies per-session CAS over a matched snapshot: a session someone edited between preview and apply is skipped, not stomped, and reported as skipped: [{ sessionId, rev }] in the response (re-run to retry). Every refused write is audited as session.conflict (ids and revs only - never input values) and folded into GET /api/v1/stats/overview's sessions.conflicts30d.

Each accepted PUT, and each session a bulk apply changes, is stored as one commit: the new document, its entry in GET /api/v1/sessions/:id/revisions and a save version. A PUT also carries its session.update audit entry when policy.activity.auditSessionUpdates is on. A write that fails stores none of them. A restore on a host without a collaboration gateway, and the recovery of a live room's unsaved work after a crash, are stored the same way. A live room's own edits reach the revisions list when the room closes.

While a live collab room holds a session, no rev can be written: PUT and DELETE answer 409 COLLAB_ACTIVE (no current, and not audited as a conflict), and bulk skips the session with reason: "collab-active" in its skipped entry. Save again once the room closes.

invitees autocompletes over eligible principals only - project membership plus collab.join, never the directory, and the admin/owner "sees every project" bypass does not make someone invitable. Prefix match on display name, capped, self excluded, no email addresses: a person is named by first and last name, or by the part of their address before the "@". invites enforces the same predicate server-side and delivers through the inbox (kind: "collab", data.sessionId for the deep link); re-inviting refreshes the pending message instead of adding a second.

Project files

Files shared inside a project (sharing). While sharing.projectFiles in org-config is false (policy off, or the memory store), every route here answers 404 NOT_FOUND with the message project files are off.

RouteWhoAnswer
GET /api/v1/projects/:id/filesviewer200 { files, limits }
POST /api/v1/projects/:id/fileseditor and session.create, project not archived; a person, so a service token gets 403201 { file, partBytes }: the upload is reserved
PUT /api/v1/projects/:id/files/:fileId/parts/:nthe uploader204; the body is part n's bytes
GET /api/v1/projects/:id/files/:fileId/partsthe uploader200: which parts are stored, so an interrupted upload sends only the rest (see below). Asking does not move expiresAt
POST /api/v1/projects/:id/files/:fileId/finalizethe uploader200 { file } with ready: true; repeating it is harmless
GET /api/v1/projects/:id/files/:fileIdviewerthe bytes, as an attachment, private, no-store
PATCH /api/v1/projects/:id/files/:fileIdeditor and session.edit, project not archived; a person, so a service token gets 403200 { name }; the body is { name }, 1 to 200 characters with no control characters. The bytes, id and checksum do not change, so sessions that use the file keep working. 400 INVALID_INPUT for a bad name, 403 without editor access, 404 for an unknown or unfinished file or a file in another project
DELETE /api/v1/projects/:id/files/:fileIdthe uploader, or manager and up204; on an unfinished upload this cancels it

The begin body is { name, size, checksum, contentType, parts: [{ size, checksum }], asset }, where checksum is the hex SHA-256 of the whole file and of each part, every part but the last is partBytes (1 MiB) long, and asset is the shell's own description of the file. The instance keeps only type and format (up to 64 characters each), width and height (numbers above zero) and meta.name (up to 200 characters) from asset, and drops anything else, a credential included. file in the begin answer also carries parts and expiresAt. expiresAt is 15 minutes after the begin; each accepted part moves it to 15 minutes after that part, but never past policy.projectFiles.uploadTtlHours after the begin.

The parts report is { id, ready, size, checksum, expiresAt, partBytes, parts, missing, receivedParts, receivedBytes, offset }. missing lists the part numbers not yet stored, in order; a stored part whose bytes no longer match its digest is listed too. offset is the bytes in the run of stored parts from the start. A finished file reports ready: true, no expiresAt and nothing missing, so a client whose finalize answer was lost learns the file is complete. An expired upload answers 410 UPLOAD_EXPIRED. Resumable uploads explains the protocol and the choice of it over tus.

Each files row is { id, projectId, name, size, checksum, contentType, ready: true, asset, createdAt, createdBy, createdByName? }, newest first. Only finished files are listed. createdByName never falls back to an email address. limits is { partBytes, maxBytes, projectBudgetBytes, projectUsedBytes, instanceRemainingBytes }, where the used and remaining figures count finished files and unfinished uploads that have not expired, each at its size plus 4096 bytes for its database rows.

CodeStatusWhen
PROJECT_FILE_TOO_LARGE413begin: size is over policy.projectFiles.maxFileBytes
PROJECT_FILE_BUDGET413begin: the project's files would pass projectBudgetBytes
INSTANCE_FILE_BUDGET413begin: all projects' files would pass instanceBudgetBytes
PROJECT_FILE_PENDING413begin: the caller already has 16 unfinished uploads, or their declared sizes with this one would pass twice maxFileBytes
INVALID_INPUT / INVALID_PART400malformed metadata, or no such part
CHECKSUM_MISMATCH422a part, or the finished file, does not match its declared digest
FILE_READY409a part sent to a finished file
UPLOAD_INCOMPLETE409finalize before every part arrived
UPLOAD_EXPIRED410the upload passed its expiry (15 minutes without an accepted part, or uploadTtlHours after the begin) or was cancelled
FILE_IN_USE409delete of a file that live sessions in the project use; sessions: [{ id, title }] identifies them. A manager may add ?force=1
RATE_LIMITED429download: this person already downloaded twice instanceBudgetBytes today, as counted by this server process; retry-after gives the seconds to wait

Errors keep the usual { "error": { "code", "message" } } shape. Finishing an upload is audited as project.file-upload, a rename as project.file-rename (with the fileId), a delete or cancel as project.file-delete (with forced: true and the session ids when ?force=1 was needed). An unfinished upload more than an hour past its expiry is removed, parts first, when someone next begins an upload (up to 50 at a time) and by POST /api/v1/retention/run (up to 500), whose answer then carries projectFilesSwept. The long-lived server also sweeps them at boot and daily, whatever the retention policy says. A serverless deploy has no timer, so there their parts stay in storage until one of those runs; they count toward no budget meanwhile.

Uploads

Private, resumable uploads that a person or a service token names by reference (resumable uploads). They use the same parts protocol as project files. A catalog-submission upload is the original for POST /api/v1/catalog/submit?uploadId=…&checksum=…, which removes it once the submission is stored. An automation-input upload is read by upload: { id, checksum } in /api/v1/inspect and /api/v1/optimize, as often as its owner likes until it expires. A finished file upload is a file reference, which its owner can also read back. Nobody downloads an upload in the other two scopes. policy.uploads.enabled is false by default, and while it is, every route here answers 404 NOT_FOUND with the message uploads are off.

RouteWhoAnswer
GET /api/v1/uploadsa member or a service token200 { uploads, limits }: the caller's own uploads that have not expired, newest first, at most 50
POST /api/v1/uploadscatalog.submit for scope catalog-submission, tool.use for automation-input and file; a member or a service token, never a guest201 { upload, partBytes }: the upload is reserved
GET /api/v1/uploads/:idthe owner200 { upload }
GET /api/v1/uploads/:id/partsthe owner200: the parts report, as for project files
PUT /api/v1/uploads/:id/parts/:nthe owner204; the body is part n's bytes
POST /api/v1/uploads/:id/finalizethe owner200 { upload } with ready: true, and file for scope file; repeating it is harmless
DELETE /api/v1/uploads/:idthe owner204; parts first, then the record

The begin body is { scope, name, size, checksum, contentType, parts: [{ size, checksum }] } with the same digest and part rules as a project file; scope file also takes role, derivedFrom and report as below. An upload is { id, scope, name, size, checksum, contentType, ready, createdAt, expiresAt }; the begin answer adds parts. An unfinished upload expires like a project file, at most policy.uploads.uploadTtlHours after its begin; a finished one expires policy.uploads.readyTtlHours after it finished, or policy.uploads.fileTtlHours for scope file. Every route but the list and the begin answers 404 NOT_FOUND for an upload the caller does not own, so an id reveals nothing. limits is { partBytes, maxBytes, ownerBudgetBytes, ownerUsedBytes, instanceRemainingBytes }.

CodeStatusWhen
UPLOAD_TOO_LARGE413begin: size is over policy.uploads.maxFileBytes, or over policy.submit.maxBytes for a catalog original; maxBytes gives the cap
UPLOAD_BUDGET413begin: the owner's uploads would pass ownerBudgetBytes
INSTANCE_UPLOAD_BUDGET413begin: all owners' uploads would pass instanceBudgetBytes
UPLOAD_PENDING413begin: the owner already has 16 unfinished uploads
INVALID_INPUT / INVALID_PART400an unknown scope, malformed metadata, or no such part
CHECKSUM_MISMATCH422a part, or the finished upload, does not match its declared digest
UPLOAD_READY409a part sent to a finished upload
UPLOAD_INCOMPLETE409finalize before every part arrived
UPLOAD_EXPIRED410the upload passed its expiry

A consumer given a reference it cannot use answers 400 INVALID_INPUT for a malformed one and 404 NOT_FOUND for one the caller does not own, in the wrong scope, unfinished, expired or with another checksum. Finishing an upload is audited as upload.finish and a cancel as upload.delete, with the scope, size and checksum but not the name. Expired uploads are swept like project files. Erasing an account removes every upload the person owns, or answers 409 ERASE_UPLOADS_REMAIN and changes nothing else while any remain.

File references

A file reference is an opaque id for bytes kept on this instance, with their digest, an expiry and the operations its owner can run on them. Upload once, then inspect, optimize and read back by reference, without sending the bytes again or getting them back in a JSON body. A reference is a finished upload in scope file, so it shares the uploads budgets, caps, sweep, erasure and policy.uploads.enabled switch. It lasts policy.uploads.fileTtlHours (a week by default) after it finished, and outlasts a restart.

A reference belongs to the account that made it: a person, from any of their sessions, or one service token. A service token's references are its own, not those of the person who minted it, so a new token does not see an old token's references. Nobody else learns that a reference exists. Every route on one reference, and every request that identifies one, answers the same 404 NOT_FOUND for someone else's reference as for an id that was never issued. Two owners who keep the same bytes get two references.

RouteWhoAnswer
GET /api/v1/files?limit=&cursor=a member or a service token with tool.use200 { files, nextCursor }: the caller's own references, newest first, 50 a page by default and at most 50. nextCursor is opaque, or null on the last page
POST /api/v1/filesas above201 { file }: the bytes in the JSON body are kept as a reference
GET /api/v1/files/:idthe owner200 { file, report }
GET /api/v1/files/:id/contentthe owner200 with the bytes
DELETE /api/v1/files/:idthe owner204; the bytes go first, then the record

POST /api/v1/files takes { name, contentType, bytesBase64, checksum?, role?, derivedFrom?, report? }, with the bytes as canonical base64, up to 4 MiB or policy.uploads.maxFileBytes if that is smaller. checksum is the sha256 you expect; other bytes are refused. Behind a request cap smaller than the body, such as Vercel's 4.5 MB, or for a larger file, begin an upload with scope file instead and send it in parts; lw upload <file> --scope file does that.

role is original unless the bytes are the output of an operation done elsewhere. An output may name derivedFrom: { id, checksum }, one of the caller's own references, and carry a report: a FileOperationReportV1 from @lolly-tools/core/file-operation-v1, at most 64 KiB of JSON, that passes that contract's check. Work keeps the report the owner supplied and labels it so; it does not check the claims in the report.

A file is { id, name, contentType, size, checksum, role, derivedFrom, reportedBy, createdAt, expiresAt, operations, unavailable }. checksum is the sha256 of the exact bytes. reportedBy is owner, instance or null. operations lists what the owner can do with this reference, each as the request to make: read, inspect, optimize for a PNG, JPEG or SVG, and delete. unavailable lists an operation this reference cannot take, with a code and a reason.

Reading back streams the bytes as an attachment with x-content-type-options: nosniff and a sandboxing content-security-policy, whatever the declared type, so a reference never runs as a page. ETag is the quoted sha256 and Repr-Digest carries it as sha-256=:…:. Each part is checked as it streams, and a body that fails its declared digest is cut short. One owner may read back twice policy.uploads.instanceBudgetBytes a day, counted per server process; past that the answer is 429 RATE_LIMITED with retry-after.

The document verbs take a reference where they take bytes: file: { id, checksum } in /api/v1/inspect and /api/v1/optimize. /api/v1/optimize with output: "file" keeps its output as a new reference of the caller, with role: "output", derivedFrom naming the input reference when there was one, and a report this instance wrote (reportedBy: "instance"). It answers { file, bytes, savedBytes, stages } instead of bytesBase64. Queued (?async=1) requests read the reference when they run.

Nothing about a reference or its caller is cached. Every request resolves the caller, checks tool.use against the current grants and loads the reference again, so each of these refuses the very next request:

A queued (?async=1) request is checked again when it runs, against the account that queued it rather than the session. Deleting the reference, revoking the token, disabling the person, denying tool.use, expiry and erasure each make it a failed job that reads nothing, with the reason in its error; these status codes do not apply to this route. Signing out everywhere ends sessions only, so work already queued for the account still runs. To stop that work too, disable the person or revoke the token before it runs.

A reference counts toward the budgets at its size plus 4096 bytes for its database rows, plus the size of its derivedFrom and report as JSON, so a report takes room like the bytes do.

Document agents, which act for a person inside one document, cannot use the person's file references. Making a reference is audited as file.create and deleting one through these routes as file.delete, with the size, checksum and role but not the name. Reads are not audited. Discovery reports files.references, which is true while policy.uploads.enabled is.

CodeStatusWhen
UPLOAD_TOO_LARGE413create: the bytes are over 4 MiB or policy.uploads.maxFileBytes
PAYLOAD_TOO_LARGE413create: the body is too large to read
UPLOAD_BUDGET / INSTANCE_UPLOAD_BUDGET / UPLOAD_PENDING413as for an upload begin
CHECKSUM_MISMATCH422create: the bytes are not the checksum given
INVALID_INPUT400malformed base64, name, media type, role, derivedFrom, report, limit or cursor
RATE_LIMITED429read back: the owner's daily amount is used up

The collab socket

Live co-editing runs over GET /ws/collab/:sessionId, a WebSocket upgrade carrying the same session cookie an HTTP call would. Authorization happens before the handshake completes, so a refusal is a plain HTTP status on the socket rather than a mystery disconnect: 401 unauthenticated, 404 no such session, 403 the project is not visible to you or collab.join is denied, 410 the session is in the bin, 429 too many connections or reconnects, 503 busy or shutting down. Write access is not a refusal: a member who may read but not edit is seated as an observer.

One optional field rides the upgrade URL, and a client that omits it is unaffected:

ParamMeaning
dsthe brand profile the client is rendering with
dsithe instance base that design system came from

A room hosted here runs under exactly one design system, the one this deployment governs (OSS plans/186 section 3.10). When a client identifies one, dsi must be this instance's baseUrl (a trailing slash and letter case are not a difference, and the comparison is on the origin) and, on a profile-aware pack, ds must be the active brand profile that GET /api/v1/brand/profiles reports. A mismatch is refused 403 DESIGN_SYSTEM_MISMATCH, whose body names the design system to switch to. On a pack with no brand profiles there is nothing to compare a name against, so only dsi is checked. Sending neither param joins as before; sending one of the two checks only that one.

Clients can negotiate interactionVersion: 1 in their join frame. Transform and text claims belong to the live room, expire after ten seconds without renewal and end on disconnect, demotion or object deletion. Acquisition covers all requested objects atomically. Previews use document coordinates and never write the session. Durable operations carry their claim ID; conflicting or expired claims are refused.

Canvas asset fields carry bounded lolly-asset-v1: references on the scalar lane. These contain an asset ID, type, format, optional dimensions and an immutable project file version; local URLs, file bytes and metadata do not travel in room operations. The session projection stores ordinary asset reference objects so project file usage, reopening and export retain their existing behavior. The shell uploads a device image before sending its reference and restores project bytes before painting a received image. The interaction-v1 shell understands these references. Older members receive ordinary fields and retain their already-restored images; extension strings are omitted from their operations and checkpoints. Live delivery of a newly added image needs the current shell. Clients that do not negotiate this version continue to use the existing protocol.

Canvas comments

Comments survive after the live room closes and have their own revisions. Every request requires a signed-in person (a service token gets 401), current session read access (404, 403, 410 as for the session) and session.view plus comment.view over the session (403). That one rule also decides who gets comment notices in the inbox and who gets live comment events. policy.comments.enabled: false disables review on the instance.

RouteResult
GET /api/v1/sessions/:id/comments{ enabled, permissions, threads, reads, readFloor, notices, features }, below. ETag: "cm-<24 hex>" and cache-control: private, no-store; a matching If-None-Match answers 304
GET /api/v1/sessions/:id/comments/:threadId{ thread, readAt? }, one thread, for a client that heard a live event. 404 when the thread is not in this session
POST /api/v1/sessions/:id/commentsCreate a thread using id, messageId, anchor, body and optional mentions; 201 { thread, notified }
POST /api/v1/sessions/:id/comments/:threadIdreply, edit, delete, resolve or reopen, with the current thread revision; reply and edit take optional mentions; 200 { thread, notified }
POST /api/v1/sessions/:id/comment-readsMark threads read for the caller: { threadIds?, at? }, at most 100 ids, every thread in the session when threadIds is absent, at an ISO time (now by default, never later than now). Deletes the caller's notices for those threads. 200 { readAt, count }; 404 when an id is not a thread in this session
GET /api/v1/sessions/:id/comment-people?q=Who the caller may mention: 200 { people: [{ id, name }], truncated }, at most 20, prefix match on the name (q up to 64 characters), sorted, caller excluded, never an address. Only for someone who may comment here; 403 when commenting or mentions are off

The listing's fields:

Read state is private to each person: no route reads or changes anyone else's, and a thread from another session is 404 on every route that identifies one.

Mentions. mentions is a list of at most 10 person ids (400 INVALID_INPUT otherwise). The server keeps only the people who can open the session and read its comments (an owner, an explicit member or a member of one of the project's groups, not disabled, with collab.join, session.view and comment.view), never the writer and never a service account, and stores each as { id, name } with the name from its own records. A mention never grants access. An edit without mentions keeps the earlier mentions whose @Name is still in the text. Each person is told about a message once, however often an edit removes and adds them again. Someone who was not told (over a limit below, or a failure) is told by a later edit that still mentions them.

Notices. After a write is saved, the people it concerns get an inbox notice: the people mentioned, and for a reply the thread's author and everyone who wrote a message in it that is not deleted, never the writer. Resolving, reopening and deleting tell nobody. At most 50 people per message, and one writer causes at most 120 notices in 10 minutes. notified is false when someone who should have been told was not (over either limit, someone mentioned while notices are off, or a failure, which is audited as comment.notice.failed); the comment itself is saved either way. See the inbox for how a notice reads.

Live events. Where features.events is true, each saved create, reply, edit, delete, resolve and reopen sends { "t": "comment", "threadId": "…", "revision": n } on the collab socket to every seat of a person in the room who may read comments, the writer's own tabs included. Guests and agents get none. The frame carries no text; a client fetches the one thread.

Limits. Per person: comment writes (every create and every action) 30 a minute and 300 an hour; comment-reads 120 a minute; comment-people 60 a minute. Past a limit the answer is 429 RATE_LIMITED with retry-after in seconds (3600 when the hourly write limit is the one reached, otherwise 60).

Policy. policy.comments takes enabled (default true), mentions (default true; false stores no mentions and refuses comment-people), notices (default true; false writes no notices and hides the existing ones) and emailTitles (default false). Mention email is a seam that is off in this release. When it is switched on, a new mention is mailed as "Ana mentioned you on <workspace>" with a link to the thread, never the comment text, and identifies the document only with emailTitles: true. Replies are not mailed.

Viewers may comment when comment.create permits it, without gaining artwork editing rights. People may edit or delete their own messages with comment.edit. Editors with comment.resolve may resolve any thread; managers with comment.moderate may delete other messages. Authors may resolve their own threads. Archived projects permit reading only. Access removal takes effect on every request.

Point anchors contain kind: "canvas", a surface ID and document-space x/y. Object anchors contain kind: "object", a collection, stable object ID, surface ID and normalized x/y between zero and one. Deleting the object leaves its thread findable; restoring that stable ID restores the pin. Creation requires a live object. Retrying the same accepted thread or reply ID is idempotent, including after its object is deleted. Conflicting IDs and stale revisions return 409.

Limits are 100 threads per session, 50 messages per thread, 4,000 characters per message and 64 KB per stored thread. Message bodies are plain text. Attribution and timestamps come from the server; audit records contain identifiers, not message text.

Session versions

Saved versions of a document (plan 76 milestone 4). What is kept, and for how long, is in data lifecycle. Every route needs a signed-in person (a service token gets 401), current session read access (404, 403, 410 as for the session) and session.view. Saving, naming, pinning and restoring also need editor access and session.edit (403 READ_ONLY) and a project that is not archived (409 PROJECT_ARCHIVED). Deleting needs a manager of the project (403 FORBIDDEN).

RouteResult
GET /api/v1/sessions/:id/versions?limit=&before=200 { versions, before? }, newest first. limit is 1 to 100 (30 by default); before is a version id to page on from, and the answer carries one when a full page came back. cache-control: private, no-store
GET /api/v1/sessions/:id/versions/:versionId200 { version } with its inputs and meta
POST /api/v1/sessions/:id/versionsSave the document as it is stored now, with a name: { label, requestId }, a label of 1 to 120 characters. 201 { version }; the same requestId again answers 200 with the first version. Audited session.version.save
PATCH /api/v1/sessions/:id/versions/:versionIdName, rename or pin a version: { annotationRev, label?, pinned? }, below. 200 { version }. Audited session.version.annotate
POST /api/v1/sessions/:id/versions/:versionId/restoreRestore it: { requestId }. 200 { revision, live, restored, before, skipped, vetoed }, below
DELETE /api/v1/sessions/:id/versions/:versionIdDelete it, with its restore pair. 200 { deleted: true }. Audited session.version.delete

A version is { id, sessionId, rev, kind, label?, contributors, createdBy?, createdByName?, restoredFrom?, beforeId?, bytes, at, pinned, annotationRev }. kind is auto, close, save, named, restore or before (the document a restore replaced). contributors are { id, kind, edits, name } for the people and agents who edited since the version before it; guests are counted together as one entry named "Guest". Names never carry an email address.

Name and pin. PATCH changes only a version's label and pinned; its content and everything else stay as they were saved. Send the annotationRev you read with the version, and label (1 to 120 characters, or null to remove the name), pinned (true or false), or both; a field you leave out keeps its value. Each change adds one to annotationRev. When someone else changed the name or pin since you read it, the answer is 409 VERSION_CHANGED with the current version in the error, so you can show it and try again. A request that asks for the name and pin the version already has answers 200 and changes nothing, so a repeated request is safe. A version saved with a name can be renamed but keeps a name (400). A named or pinned version is kept: history never removes it for age or space, and it counts against the named limits of the person who first named or pinned it, until neither name nor pin is left.

Restore. On the long-lived server a restore goes through the document's live room, which it opens when none is open, as one batch that everyone in the room receives, and it needs collab.join (403). The restoring person's own write checks apply: an input they may not change keeps its value and is named in vetoed, and an input the tool does not declare, or of the wrong type, is named in skipped. On a serverless deploy it is a compare-and-swap on the session row. Two versions record it, with the same requestId: the before version, the document it replaced, which Undo restores, and the restore version, the document it produced. In the answer, before is the id to restore for Undo, restored is the restore version's id (or null when history had no room for it), revision is the session's revision after it and live says whether anyone else was in the room. The same requestId again answers with the first result. Comments are never changed. The audit entry session.restore holds ids and counts.

Saves, name and pin changes and restores share one limit per person: 10 a minute and 60 an hour (429 RATE_LIMITED, retry-after in seconds).

CodeStatusWhen
VERSION_LIMIT409a named save, or naming or pinning a version, past 20 kept versions per person or 100 per document
VERSION_CHANGED409the version's name or pin changed after the annotationRev you sent; the error carries the current version
VERSION_SPACE409a named save, or the before version of a restore, does not fit the space caps (100 MiB per document; policy.versions.maxBytes for the instance)
RESTORE_INCOMPLETE409the document cannot take every change of the version (a ceiling such as the number of objects); nothing was changed
SESSION_CHANGED409the document kept changing while restoring, or it is open on another server; try again
NOT_FOUND404the version is not one of this session's
INVALID_INPUT400a missing or malformed label, pinned, annotationRev, requestId, limit or before, or removing the name of a version saved with one

History across documents

GET /api/v1/history lists the versions of every document you can read, newest first, in one request (plan 221). It needs a signed-in person: a service token gets 401, and a request an agent makes for a person gets 403, because an agent's access is one project or one document. The answer is 200 { entries, before? }, private to you (cache-control: private, no-store).

What can appear is decided on the server before anything is read, with the same rules as reading one document's versions: access to the project (membership, its end date, groups and sharing) and session.view on the document. A document in a project you cannot see, a deleted document, and a document a grant denies you never appear, and nothing in the answer says they exist.

Without project, the page covers your own projects, as your Projects list shows them: projects you own or were added to (directly or through a group), projects you pinned, and projects you opened in the last 30 days. A project you hid never appears, and an archived project appears only with archived=1. A project shared with everyone, or one an administrator can see only as an administrator, appears once you pin it, or for 30 days after you open the project.

ParameterMeaning
limit1 to 100 entries, 50 by default
beforethe before of the page you read last, to read the next one. It identifies the last entry of that page and is otherwise opaque. A page that came back full carries one
projectone project you can see, including one you hid or archived: 404 when there is none, 403 when you cannot see it
toolone tool id
kindone or more of auto, close, save, named, restore and before, separated by commas
kept=1only versions kept by a name or a pin
from, toa date and time; both ends are included
archived=1include your archived projects

An entry is a version as GET /api/v1/sessions/:id/versions lists it (above), with projectId, projectName, projectArchived (only when it is), toolId, toolVersion and sessionLabel (the document's current name, or null). It never carries the document's content or an email address. Read a version's content with GET /api/v1/sessions/:id/versions/:versionId. A malformed parameter answers 400 INVALID_INPUT.

Telemetry, activity, audit, fleet, system

RouteAction
POST /api/v1/telemetrymember or guest session; attribution per level/consent
POST /api/v1/telemetry/consentmember
GET /api/v1/telemetry/summarytelemetry.view
GET /api/v1/stats/overviewtelemetry.view
GET /api/v1/stats/series?days=Ntelemetry.view - day-bucketed audit-action counts (counts only), the console's per-view activity headers; days clamps 7–90
GET /api/v1/activityaudit.export
GET /api/v1/audit?limit=&before= (paged: the limit newest events, or those older than the before seq; nextBefore is the cursor while older rows exist), GET /api/v1/audit/headaudit.export
GET /api/v1/fleetfleet.view - the version histogram, plus engineVersion (this deploy's vendored pin)
GET /api/v1/fleet/installsfleet.view - registered installs, newest activity first
PATCH /api/v1/fleet/installs/:idfleet.manage - set or clear the operator name
DELETE /api/v1/fleet/installs/:idfleet.manage - forget the row (bookkeeping; the device is untouched)
GET /api/v1/system/setupinstance.config: safe running checks, pack, store and schema
GET /api/v1/system/setup/configurationinstance.config: non-secret settings, callback, environment presence and retained account/test evidence
POST /api/v1/system/setup/configurationinstance.config: validate a setup draft and return deployment/Helm files; never saves live deployment settings
POST /api/v1/system/setup/identity-testinstance.config: bounded installed-issuer discovery, audited result
POST /api/v1/system/setup/account-testinstance.config: exact {sub} lookup and retained SCIM/sign-in correlation; no email merge
GET /api/v1/system/setup/tools/:idinstance.config plus current tool use/export permission: sample input metadata and governed checked formats
GET /api/v1/system/migrationsinstance.config (owner)
GET /api/v1/docs, GET /api/v1/docs/:slugmember - this documentation set

Static

RouteNotes
GET /admin, GET /admin/*the admin console (static; every call it makes is auth-enforced)
GET /tools/*a tool's files (tool.json, template.html, hooks.js, ...) from the pack's tools/<id>/, never from the shell dist. Gated like /catalog/*, and a tool the caller's groups cannot see answers 404, the same absence the tool index shows. A guest may fetch the tool its link opens
GET /, GET /*the Lolly web shell, when instance.shellDir is set. Registered last, so API/console/catalog/render/link routes always win; api, catalog, tools, render, l, admin, healthz are reserved prefixes

Common error codes

CodeStatusMeaning
UNAUTHORIZED401no session, or this deployment is sign-in gated
FORBIDDEN403the required action is missing
OWNER_ONLY_ACTION403an owner-only escalation was attempted
GUEST_LINKS_DISABLED403guest links are off on this deployment
BAD_SIGNATURE403link signature invalid
CONFIG_MANAGED409the target is owned by instance.json
CONFLICT409stale session rev - the body's current is the server session to rebase on
LINK_EXPIRED / LINK_REVOKED410self-explanatory
INPUT_LOCKED422a locked input was supplied by the caller
INPUT_HIDDEN422the caller supplied an input that policy hides from them
INPUT_NOT_ALLOWED422the value is not one of those a choice rule allows
BRAND_RULE_VIOLATION422a render input breaks a reviewed design-system rule for that format
RATE_LIMITED429too many requests of one kind from one person; retry-after gives the seconds to wait
INVALID_INPUT400an automation request is missing or has malformed required fields
DOCUMENT_API_ERROR400the requested compile/inspect/diff/measure/optimise/package operation could not be performed
DATA_BINDING_ERROR400a live JSON/CSV provider binding could not resolve, parse, query or validate
ROW_VALIDATION_FAILED422a batch row does not satisfy the selected tool contract
IDEMPOTENCY_KEY_REUSED409the principal reused an idempotency key for different request bytes
UNSUPPORTED_FORMAT400a format this deployment cannot produce (org_config's render.formats identifies what it can)
FORMAT_NOT_ALLOWED403the format exists here but this tool's overlay policy excludes it
RENDER_BUSY503the render worker is at capacity - retry after Retry-After seconds
HOOKED_TOOL_NEEDS_CHROMIUM501hooked tool, no worker configured (org_config's render.hookedTools is false)
TOOL_REQUIRES_UNMET501the manifest's requires identifies a host.* API the in-process render host lacks (text, compose, audio, the device APIs); route the tool through the Chromium worker

Client identification

Shells send X-Lolly-Client (shell, shell version, engine, platform). It feeds the fleet histogram and message targeting; it is never trusted for authorization. The OSS shells add an install/<id> token while - and only while - their person is signed in: on a request that carries a live member session, it registers the install in the fleet registry. Anonymous and guest traffic with the same token feeds the histogram and nothing else, and there is no heartbeat: an install is seen exactly when its person uses the instance, and leaving the instance client-side deletes the id.

Automation and document API

The typed automation surface mirrors the open-source engine's document verbs: POST /api/v1/compile, /validate, /inspect, /diff, /measure, /optimize, /package, and /render; GET /api/v1/schema/:toolId publishes a tool's input schema. JSON requests use {toolId, inputs} rather than URL-only merge fields. Schema, compile, package and render all enforce the caller's tool.use decision and tool-visibility overlay; a document verb cannot be used to discover a tool hidden from that principal. Render additionally requires export.server.

Effective schemas and discovery

RouteActionNotes
GET /api/v1/discoverypublicWhat this deployment can do, the same for everyone: document API and engine versions, server render formats, hooked tools, Content Credentials, automation verbs and batch limits, live collaboration, document agents, whether file references are on (files.references), and unavailable entries for what it lacks. Shared caches may hold it; it carries an ETag
GET /api/v1/discovery/memember, service token or open guestWhat the caller can do: principal (member, service or guest), can bits for tool.use, export.server, export.request, delivery.create and catalog.read, provider reference schemes that resolve for the caller, limits, the render worker's live readiness when one is configured, and unavailable entries. Private, never cached
GET /api/v1/discovery/tools?limit=&cursor=as aboveThe tools the caller can use, sorted by id, 25 a page by default and at most 100. Each entry has name, version, renderer, the server formats the caller may request, can.render, its schema URL and unavailable entries. next is an opaque cursor, or null on the last page
GET /api/v1/schema/:toolId?view=&format=tool.useThe caller's effective input schema (below). view=raw returns the tool's own schema and needs policy.edit. format selects the output format whose design-system rules apply. Without it, the schema carries only the rules that apply to every format

GET /api/v1/schema/:toolId keeps its apiVersion and schema fields. The schema is now the effective one for the caller, built from the tool, the tool policy that applies to the caller, the design-system choice rules for the format and the deployment's render probe:

The response adds view, toolId, tool (name, version), versions (engine, tool, policy, design, catalog, and rules when design-system rules apply), policy, render (renderer, formats, and format when one was requested), unavailable and etag. Each policy entry is one change the policy made, an effect of locked, choice or format-excluded with the input or format, plus the overlay's name as by and the rule's reason when they exist. Entries never name groups or values from rules that do not apply to the caller. The ETag header matches etag and moves with any change to the effective contract, so If-None-Match answers 304 while it holds.

Each unavailable entry identifies one format, action or feature with a stable code and a reason:

CodeMeaning
WORKER_NOT_CONFIGUREDthe format needs the Chromium render worker, and this deployment does not run one
UNSUPPORTED_FORMATthe deployment does not produce this format on the server; the web app can still export it
HOOKED_TOOL_NEEDS_CHROMIUMthe tool runs scripts, and no worker is configured
TOOL_REQUIRES_UNMETthe tool needs host APIs the in-process render host does not provide
SVG_TEMPLATE_REQUIREDserver rendering needs an SVG template
FORMAT_NOT_ALLOWEDtool policy does not allow the format
FORBIDDENthe caller lacks the permission, such as export.server for render
RENDER_BUSY / WORKER_UNREACHABLEthe configured worker is at capacity or did not answer its readiness check
TOOL_UNAVAILABLEthe deployment cannot load the tool

Compile, validate, package and the rows of POST /api/v1/batch check the caller's inputs against the same policy the schema shows. Compile and package refuse a locked, hidden or not-allowed input with 422 and the code the render route uses (INPUT_LOCKED, INPUT_HIDDEN or INPUT_NOT_ALLOWED), naming the input, by and reason, and compile with the locked values applied. Validate reports the same codes as errors and does not count a missing locked or hidden input against the caller. A batch row that breaks the policy fails ROW_VALIDATION_FAILED before the job is queued. Durable renders and render batches check the current policy at each attempt, as before, so one admitted before a policy change records that attempt's failure with the same codes.

Design-system rules can apply to some output formats only. Compile, validate and package accept an optional format, one the tool declares, and then apply that format's rules, as the schema read with the same format shows. Without one, they apply only the rules every format shares, as the default schema shows. A batch row is checked against the rules for the batch's format, and a render against the rules for its format. Design-system rules that are not choices, such as a text length, are not in the schema; the render checks them and refuses a value that breaks one with BRAND_RULE_VIOLATION.

The discovery routes and the schema route share the rateLimit.automation bucket. The public deployment record uses it too, so reading that record never costs a sign-in attempt.

Discovery is served under /api/v1 rather than /.well-known/, because on a shared origin /.well-known/ belongs to the web shell, whose static /.well-known/lolly.json describes the shell.

Jobs, batches and provider references

Pass ?async=1 or Prefer: respond-async to receive 202 {jobId,statusUrl}. Poll GET /api/v1/jobs/:id, download a completed result from GET /api/v1/jobs/:id/result, or list the caller's jobs at GET /api/v1/jobs. Completed job resources include resultSha256, computed from the output bytes rather than blob provider metadata; it is null until a result exists. DELETE /api/v1/jobs/:id removes queued work or retained output, except while a delivery retains that output (409 JOB_OUTPUT_IN_USE). Jobs are isolated to the member or service principal that created them. A completed render can be delivered without a download/upload round trip through POST /api/v1/jobs/:id/deliveries; the delivery shares the immutable result blob and records the source job id. Repeating a request with the same Idempotency-Key returns its existing job; reusing that key for different request bytes returns 409 IDEMPOTENCY_KEY_REUSED. A callbackUrl is used only when it exactly matches the instance-configured webhook endpoint; callback requests carry x-lolly-timestamp and a signed x-lolly-signature header. The callback body is {jobId, state, resultUrl, resultToken, deliveryId}. resultToken is a 24-hour token for that one job's result: send it as the x-lw-job-token header on GET /api/v1/jobs/:id/result, and the receiver needs no session of its own. A completed job resource carries the same resultToken, and null before then.

The token used to travel in the URL, as resultUrl?token=…. A URL ends up in access logs, proxy logs and CDN logs, so that form is deprecated. For one more release resultUrl still carries ?token= and the result route still accepts it, answering with a Deprecation header (RFC 9745). After that release resultUrl loses its query and a query token is refused, so move receivers to the header now.

Callbacks go through a durable outbox. The write that settles a job also stores its callback, and any Work process sends callbacks that are due, so a callback left unanswered by a restart is sent again afterwards. Every attempt for one job carries the same id, in x-lolly-delivery-id and as deliveryId in the signed JSON body. This is the idempotency key contract of a signed-HTTPS delivery: the receiver should act on each id once. Each attempt has a fresh timestamp, signature, result URL and result token. A refusal, a timeout or a dropped connection is retried after 5 s, 30 s, 2 min, 10 min and 30 min, six attempts in all. The job resource reports callback: {id, state, attempts, lastOutcome, lastStatus, nextAttemptAt}. state is pending, acknowledged (the receiver answered 2xx) or failed. Work never reports a callback as delivered, because a 2xx answer does not show what the receiver did next. lastOutcome is acknowledged, refused, uncertain (the request may have reached the receiver) or not-sent (the URL is not approved, or no signing secret is configured). callbackFailed stays true until the receiver acknowledges.

POST /api/v1/batch {toolId,format,rows,keepGoing?,retries?,concurrency?,priority?} always creates a job and produces one ZIP. Requested concurrency is capped at four in this process, priority at 0–9, and retries at three. Progress is exposed as {done,total} and the ZIP's manifest.json records every row outcome. In place of rows, bind:{source,query?,as?} reads JSON/CSV from a governed provider; as may be the JSON Schema returned by the schema route, and every row is also checked against the actual tool before enqueue. query is a typed, nested equality selector: it is forwarded to the provider and enforced again over the returned objects. CSV follows RFC 4180, including quoted commas, escaped quotes and embedded newlines; its cell values are strings.

Provider refs are resolved during compile and before render cache identity. Local image://brand, catalog://, and library:// refs stay offline-first. cms://<provider-id>/<remote-id> uses that enabled catalog provider's stored credential, exposure and lifecycle; net://<operator-allowed-origin>/<path> is restricted to origins present in provider configuration. Both are timeout- and byte-bounded, content-addressed, and use immutable resize, format-conversion and metadata-strip stages. /inspect and /optimize also accept bytesBase64, a provider source, upload: { id, checksum } naming the caller's own finished automation-input upload, for a file too large for one request, or file: { id, checksum } naming one of the caller's file references. /optimize with output: "file" keeps its output as a file reference; /package accepts either a compiled document or {toolId,inputs} and can run asynchronously.

These routes use the configurable rateLimit.automation bucket. It is technical admission control, not printer-rate pricing; durable per-principal quotas and usage accounting are specified in plan 45.

Durable render and batch requests accept production: { contract, referenceBase64?, repair? } for the engine's lolly/production-still-v1 profile. The lolly/production-motion-v1 profile is not accepted by Work's render API. The contract and repair choices are included in request identity and retries. Contracts can protect runtime input identities with kind: "input", a declared input id in location and a canonical JSON SHA-256 in expected. Required input checks need observed runtime values; file-only inspection cannot reconstruct them. The retained evidence contains production and optional productionAttempts; failed required checks return no artifact and retain PRODUCTION_VERIFICATION_FAILED diagnostics on the render record. See Production still checks for fields, collector limits and worker compatibility.

Managed rule mappings

RouteActionNotes
GET /api/v1/brand/rulescatalog.readPublished guide and source-specific mappings; compatible input inventory is visible to policy editors
POST /api/v1/brand/rules/previewpolicy.editValidate { mappings }, inspect format coverage, return revision and review token
POST /api/v1/brand/rulespolicy.editApply the reviewed mappings with revision and reviewToken; audited, stale-safe and durable outside development

See managed production rules for scope and draft handling. Render input checks do not certify output appearance. Durable output metadata includes brandRules with disposition, scope and revision; synchronous and durable downloads expose x-lolly-brand-check.

Project session presence

GET /api/v1/projects/:id/presence requires project viewer access and session.view. It returns private, uncached live session participants: display names, assigned colours, writer/observer roles and away state. Multiple tabs count as one person. Session deny grants and deleted sessions are respected; cursor, chat and document fields are omitted. Hosts without a collaboration gateway return available: false. Coverage is capped at 500 live sessions with an explicit truncated flag.

Agent activity

GET /api/v1/agents/activity?days=30 requires audit.export. days is an integer from 1–90 (default 30). The private, no-store response includes summary call/outcome/operation counts, the most recently observed agent invitations with inviter, scope, role, expiry and live document-room presence, and a metadata-only recent timeline. No credentials, arguments or document contents are returned. truncated and inventoryTruncated explicitly disclose the 10,000-event and 500-agent coverage limits. Room presence is null when no room host is wired. See Document agents for reporting semantics.

Project agent invitations

Agent activity uses an agent:<invitation id> audit actor with the inviting user recorded separately as invitedBy. The activity API returns actor.kind: "agent" and, when known, actor.invitedBy: { id, name }. Filtering by a user or group includes their invited agents. See Activity attribution.

These routes require a signed-in person with access to the project. They never return a stored connection key. See Document and project agent invitations for the MCP tools and their scope.

RouteAccess and response
GET /api/v1/projects/:id/agentsviewer; { enabled, canInvite, canEdit, agents }. Each row includes the inviter's display name, expiry, connection state and canRevoke; no key or hash
POST /api/v1/projects/:id/agentsviewer; { label, role, hours? }, with viewer/editor access and 1 to 168 hours (default 24). Editor access also needs project editor rights. Refused on archived projects. 201 { agent, endpoint, secret } returns the connection key once
DELETE /api/v1/projects/:id/agents/:agentIdinviter or project manager; revokes the key and closes its document connections; 204

Project keys authenticate only POST /api/workspace/mcp, using Authorization: Bearer <key>. They do not authenticate the REST API. DELETE /api/workspace/mcp disconnects document connections without revoking the key. MCP requests recheck current inviter access. Project tools accept only their declared arguments and never a caller-selected project or HTTP path. Tool failures return isError: true with a safe error code and explanation. Document tools require sessionId for a project key; a document key remains bound to its original document.