REST routes
Everything is served under /api and speaks JSON. Requests and responses are validated with the schemas in Entities.
Authentication is a session cookie (nova_session) set by POST /api/auth/login. An unauthenticated call to a protected route returns 401 with an ApiError body:
{ "error": "unauthorized", "message": "Sign in to continue" }In production the API lives at https://api.novaterra.world; in development it is http://localhost:8787. A route written GET|POST /api/x accepts both verbs at that path.
auth
| Operation | Route | Notes |
|---|---|---|
register | POST /api/auth/register | RegisterRequest -> AuthResponse |
login | POST /api/auth/login | LoginRequest -> AuthResponse |
logout | POST /api/auth/logout | -> Ok |
me | GET /api/auth/me | -> AuthResponse |
waitlist | POST /api/auth/waitlist | WaitlistRequest -> Ok (public) |
codes | GET|POST /api/auth/codes | owner only: list / mint {count, note} -> AccessCode[] |
codesUnused | GET /api/auth/codes/unused | owner only: settings-page convenience, unused codes only -> AccessCode[] (added 2026-09-05, catalogued 2026-09-06) |
codesRevoke | DELETE /api/auth/codes/:code | owner only: revokes an unused access code (400 if already used, or the bootstrap code) -> Ok (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
logoutAll | POST /api/auth/logout/all | signs the caller out of every device -> {ok: true, revoked: number} (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
sessions | GET /api/auth/sessions | the caller’s active sessions -> Array<{id, createdAt, expiresAt, current}> (token fingerprints only) (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
sessionsRevokeOthers | DELETE /api/auth/sessions | revokes every other session, keeps this one -> {ok: true, revoked: number} (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
waitlistRows | GET /api/auth/waitlist | owner only: waitlist rows as JSON -> Array<{email, name, why, createdAt}> (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
waitlistCsv | GET /api/auth/waitlist.csv | owner only: CSV export (email,name,why,createdAt; RFC 4180; Content-Disposition: attachment) (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
email | GET /api/auth/email | -> EmailStatus {email, verified, verifiedAt, pending, emailConfigured}. emailConfigured is false on a node with no RESEND_API_KEY, so the UI never offers a button that goes nowhere (added 2026-09-10) |
verifySend | POST /api/auth/verify/send | the signed-in caller asks for a fresh confirmation link -> EmailStatus. Always the CALLER’s own address, taken from the session: an address in the body would be a way to post mail to a stranger from this node (added 2026-09-10) |
verify | POST /api/auth/verify | PUBLIC. VerifyEmailRequest {token} -> Ok. Consumes the link. No session needed: the token names the being, and a person clicking from their mail client is commonly not signed in (added 2026-09-10) |
passwordResetRequest | POST /api/auth/password/reset/request | PUBLIC. PasswordResetRequest {email} -> Ok, ALWAYS, whether or not the address is known here. Same body and same timing either way: the mail is dispatched off the response path precisely so “did it take longer?” answers nothing. Rate limited per IP and per address (added 2026-09-10) |
passwordReset | POST /api/auth/password/reset | PUBLIC. SetPasswordRequest {token, password} -> Ok. Spends the token inside the transaction that writes the password, drops EVERY session of that being, and marks the address confirmed — arriving through a link IS proof of control of it (added 2026-09-10) |
codesSend | POST /api/auth/codes/send | owner only. InviteEmailRequest {email, note} -> InviteEmail. Mints a code and posts it, instead of the owner reading one off a screen and pasting it into a chat (added 2026-09-10) |
codesSent | GET /api/auth/codes/sent | owner only -> InviteEmail[]: who was invited, whether the mail left, and whether the code has been spent. Carries a MASK (NOVA-••••-7F3K), never the code — a screen listing dozens of live bearer credentials is the finding this one exists not to repeat (added 2026-09-10) |
beings
| Operation | Route | Notes |
|---|---|---|
list | GET /api/beings | -> Being[] |
get | GET /api/beings/:id | -> { …Being, soul?: Soul (systemPrompt stripped unless self/owner), summary?: string, online: boolean, citizen?: object } (comment corrected 2026-09-06; was documented as Being & { soul?: Soul }) |
updateMe | PATCH /api/beings/me | Partial<Being> -> Being |
square | GET /api/beings/square | town square feed -> Message[] (thread kind square) |
squarePost | POST /api/beings/square | {content} -> Message |
lifeTick | POST /api/beings/life/tick | owner only: one beat of the Square’s life -> {posts} (additive 2026-09-05) |
lifeGreet | POST /api/beings/life/greet | owner only: Luma greets me in the Square -> {posts} (additive 2026-09-05) |
souls
| Operation | Route | Notes |
|---|---|---|
get | GET /api/souls/:beingId | |
update | PUT /api/souls/:beingId | |
recompile | POST /api/souls/:beingId/recompile | |
generate | POST /api/souls/:beingId/generate | {role?, archetype?, seed?} -> Soul & {archetype, source} (additive 2026-09-05) |
affect | POST /api/souls/affect | {text} -> Affect {valence, arousal, needs[], label} heuristic, no spend (additive 2026-09-05) |
drift | POST /api/souls/:beingId/drift | {event, minutes?} -> Soul with moved mood (additive 2026-09-05) |
twin
| Operation | Route | Notes |
|---|---|---|
profile | GET|PATCH /api/twin/profile | -> Profile |
preferences | PATCH /api/twin/profile/preferences | {<key>: value|null} -> Profile |
memories | GET /api/twin/memories?q=&kind=&limit= | search (FTS + embeddings) -> Memory[] |
memoryCreate | POST /api/twin/memories | |
memoryDelete | DELETE /api/twin/memories/:id | |
activity | GET /api/twin/activity | -> TwinActivity[] |
learn | POST /api/twin/learn | {text, source} -> Memory[] extracted |
sync | POST /api/twin/sync/:provider | trigger ingestion -> {started:true} |
status | GET /api/twin/status | ingestion status per provider -> TwinStatus (added 2026-09-05) |
reflect | POST /api/twin/reflect | run the reflection pass now -> {ran:boolean} (added 2026-09-05) |
env | GET /api/twin/env | owner-visible settings hint -> {filesRoot, gmail, telegram, llm} (added 2026-09-05, catalogued 2026-09-06 — orphaned: no client call, redundant with TwinStatus’s gmail/telegram/llm flags; candidate for removal) |
voiceStt | GET /api/twin/voice/stt | -> NodeSttStatus {available, reason, provider, requested, model, baseUrl, leavesDevice, destination, costsMoney, usdPerMinute, consentKey, consented, consentedAt, note}. leavesDevice is NOT provider !== 'local': a local tier with LOCAL_LLM_FALLBACK=openrouter sends the recording on whenever the home box is quiet, so it counts as leaving (CLAUDE.md invariant 5) (catalogued 2026-09-09) |
voiceSttConsent | POST|DELETE /api/twin/voice/stt/consent | POST {consentKey} -> NodeSttStatus. Consent is bound to the DESTINATION (provider+fallback:model), not to the utterance: one agreement, void the moment any part of that fingerprint moves. The body must echo the key the sheet displayed, so an owner changing the tier mid-sheet is a 409 rather than a silent agreement to somewhere else. 400 when nothing leaves the device — there is nothing to agree to. DELETE withdraws it (catalogued 2026-09-09) |
voiceTranscribe | POST /api/twin/voice/transcribe | multipart/form-data: one audio part (webm/ogg/mp4/m4a/mp3/wav/flac as MediaRecorder produces them, 8 MB cap) plus an optional language field -> NodeTranscript {text, language, audioSeconds, bytes, format, model, provider, leavesDevice, destination, costUsd, durationMs}. 403 until the destination above has been agreed to — the localStorage acknowledgement in apps/web/src/voice/settings.ts is a UI convenience, this is the gate. The recording is buffered in memory and never written to disk (catalogued 2026-09-09) |
todos | GET /api/twin/todos?status=&projectId=&limit= | status is comma-separated TodoStatus (open|done|deferred|dropped) -> Todo[] {id, beingId, title, notes, status, priority, dueAt, projectId, memoryId, deferredCount, createdAt, updatedAt, completedAt}, sorted due-soonest first (nulls last) then newest created (catalogued 2026-09-06 — calendar/to-do/Hush build) |
todoCreate | POST /api/twin/todos | {title, notes?, priority?, dueAt?, projectId?, memoryId?} -> Todo (catalogued 2026-09-06) |
todo | GET /api/twin/todos/:id | -> Todo (catalogued 2026-09-06) |
todoUpdate | PATCH /api/twin/todos/:id | Partial<{title, notes, priority, dueAt, projectId, memoryId}> -> Todo (catalogued 2026-09-06) |
todoComplete | POST /api/twin/todos/:id/complete | -> Todo {status: ‘done’, completedAt} (catalogued 2026-09-06) |
todoDefer | POST /api/twin/todos/:id/defer | {dueAt?} (defaults to +24h) -> Todo {status: ‘deferred’, dueAt, deferredCount: +1} — deferredCount lets the Hush gate notice a to-do that keeps getting kicked down the road (catalogued 2026-09-06) |
todoDrop | POST /api/twin/todos/:id/drop | abandoned, distinct from completed -> Todo {status: ‘dropped’} (catalogued 2026-09-06) |
todoDelete | DELETE /api/twin/todos/:id | -> Ok (catalogued 2026-09-06) |
calendar | GET /api/twin/calendar?from=&to= | from/to required. The merged agenda: stored events plus any OPEN due-dated Signal in range not already linked to one (a bare schedule.create reminder) -> AgendaEntry[] {id, title, body, startAt, endAt, allDay, origin: ‘event’|‘signal’, signalId, eventId} (catalogued 2026-09-06) |
calendarCreate | POST /api/twin/calendar | {title, description?, location?, startAt, endAt?, allDay?, recurrence?, projectId?, remind?} -> CalendarEvent, plus {signalId} when remind:true, which raises a linked reminder Signal through the same action.kind:‘schedule.fire’ path schedule.create uses (catalogued 2026-09-06) |
calendarEvent | GET /api/twin/calendar/:id | -> CalendarEvent (catalogued 2026-09-06) |
calendarUpdate | PATCH /api/twin/calendar/:id | Partial<calendarCreate body, minus remind> -> CalendarEvent (catalogued 2026-09-06) |
calendarDelete | DELETE /api/twin/calendar/:id | -> Ok (catalogued 2026-09-06) |
hush | GET|PUT /api/twin/hush | -> HushSettings {beingId, level: ‘normal’|‘fewer’|‘off’, quietStart, quietEnd (minutes since local midnight), dailyCap, mutedUntil, updatedAt}. PUT body {level?, quietStart?, quietEnd?, dailyCap?, muteHours?} — muteHours snoozes every proactive nudge for that many hours from now, 0 clears an existing snooze (catalogued 2026-09-06) UPDATED 2026-09-07: these settings are now enforced at the HUB (apps/api/src/twin/hush.ts’s gateAtHub, called by raiseSignal) rather than by each caller, so a new caller inherits them by default instead of by remembering. Quiet hours HOLD a proactive signal (raised with dueAt at the end of the window, withheld by computeNow’s isDue, no WS ripple) rather than dropping it - the PRD’s ‘2 things waited for morning’. The daily cap REFUSES (written with status dismissed for the audit trail, never published, never seen); the eleventh nudge is discarded, not queued, per the PRD’s own open question 1. priority: 'urgent' does NOT override the cap - that field is chosen by a model in muse/skills.ts and derived from a stranger’s email in twin/gmail.ts, so an urgency exemption would be handed to whoever wanted it most; the only trigger that passes the cap is system (core/budget.ts), which nothing outside this node can reach |
hushHeld | GET /api/twin/hush/held | “what did I miss?” -> {settings: HushSettings, held: Nudge[], refused: Nudge[]} where Nudge is {id, title, body, kind, source, priority, createdAt, reason (the gate’s own sentence, not a rephrasing), heldUntil, countedAgainst: {used, cap}, why: the /api/twin/nudges/:id/why path}. A ceiling nobody can see the far side of is censorship with a friendly name, so a refused nudge is WRITTEN (status dismissed, action.hush stamped) rather than dropped, and this route is where the person sees what was kept from them and can raise the cap if they disagree. held and refused are separate because they are different facts - a held nudge is coming, a refused one never will be, and blurring them would let “waiting until morning” read as “gone”. Proactive signals only; local day; no model call (catalogued 2026-09-07) |
nudgeWhy | GET /api/twin/nudges/:id/why | the defensibility surface for proactive nudges: “why was I told this” -> {title, because: string, basedOn: string[], evidence: EvidenceItem[]}. basedOn is unchanged (raw ids, kept for back-compat); evidence (additive, catalogued 2026-09-06) resolves each id against the being’s CURRENT data at READ time -> {id, kind: ‘memory’|‘todo’|‘event’|‘project’|‘unknown’, label: string, snippet?: string, gone: boolean} (rule-based only: table lookup + truncation, never a model call), so an edited memory shows its current text and a deleted one reads as e.g. “a memory you have since forgotten” (gone: true) instead of a bare id or a silently dropped item. because is rebuilt from that resolved evidence (“Based on 2 things it noticed: … and …”) rather than joining raw ids; when the signal was not raised via evaluateNudge (no basedOn), because falls back to “No record of why — this was not raised through the Hush gate” and both basedOn/evidence are [] (catalogued 2026-09-06) UPDATED 2026-09-07 (the Hush at the hub): additionally returns source and gate, which answer a DIFFERENT question from because - not what this is about, but how it reached you. gate is null for a signal raised before the hub existed; otherwise {trigger: ‘proactive’|‘responsive’|‘system’, decision: ‘raised’|‘held’|‘refused’, reason, heldUntil: ISODate|null, countedAgainst: {used, cap}|null (null unless proactive), key, at}. Read off the stamp raiseSignal wrote at the moment it decided, never re-derived - re-running the gate now would answer about today’s budget rather than about the moment the person was, or was not, interrupted. A refused signal is written with status dismissed and never published, so this route can say ‘you never saw this, and here is why’ instead of having nothing to answer from |
connections
| Operation | Route | Notes |
|---|---|---|
list | GET /api/connections | -> Connection[] |
create | POST /api/connections | {provider, label?, config} -> Connection (config encrypted at rest) |
delete | DELETE /api/connections/:id | |
gmailAuth | GET /api/connections/gmail/auth | redirects to Google |
gmailCallback | GET /api/connections/gmail/callback | |
telegramPairing | GET /api/connections/telegram/pairing | -> TelegramPairing (added 2026-09-05) |
providers | GET /api/connections/providers | -> ProviderDescriptor[]: every provider the registry holds, built-in or plugin-contributed, with the auth descriptor a connect flow is rendered from. Replaces the closed ConnectionProvider enum (added 2026-09-06, OS-PLAN wave G) |
files
| Operation | Route | Notes |
|---|---|---|
list | GET /api/files?scope=&projectId=&path= | -> FileEntry[] |
search | GET /api/files/search?q= | -> FileEntry[] |
read | GET /api/files/read?id= | -> {content|base64, mime} |
download | GET /api/files/download?id= | streams file. Answers HTTP range requests: Accept-Ranges/ETag/Last-Modified on every reply, 206 + Content-Range for a single byte range, 416 for one outside the file, 304 on If-None-Match, and the whole file when If-Range does not match. Always Content-Disposition: attachment — serving user files inline from the API origin would run an uploaded .html or .svg as markup with the session cookie; a <video>/<img> subresource ignores the header and plays the bytes anyway (range support added 2026-09-07) |
downloadProject | GET /api/files/project/:projectId.zip | |
serveProject | GET /api/files/project/:projectId/* | static serve outputs (preview), inline, same range/conditional handling as download (range support added 2026-09-07) |
upload | POST /api/files/upload | multipart -> FileEntry |
write | POST /api/files/write | {path, content, scope} -> FileEntry |
text | GET /api/files/text?id=&maxChars= | extracted text + metadata for any supported file, what the Twin indexes -> {id, name, size, supported, kind, pages, truncated, text, error} (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
sheet | GET /api/files/sheet?id=&sheet=&maxRows=&maxCols= | an .xlsx/.xlsm as CELLS, for the table viewer, where text gives the index its words -> {id, name, size, totalSheets, maxRows, maxCols, sheets: Array<{name, rows: string[][], totalRows, totalCols, truncated}>}. totalRows is what the file holds, not what came back, so a viewer can say “5,000 of 214,388”. 400 for a non-spreadsheet or a workbook that will not parse; there is no silent empty table (added 2026-09-07) |
thumbnail | GET /api/files/thumbnail?id=&w= | a square webp of an IMAGE file -> image/webp bytes. w is snapped to 48|96|192|384, because a free-form width is an unbounded cache key. 415 with {error:‘no_thumbnail’, name, reason} for a PDF, a video, an archive, an SVG (deliberately: rasterising hostile XML server-side buys a prettier row and costs an attack surface), a file over 24MB, or bytes that do not decode — the Files list draws its type icon on a 415, so nothing renders as an empty box. Validated by the SOURCE file’s ETag + width with no-cache, not by max-age: the URL is keyed on the file id and does not change when the file does. Decoding is libvips on the libuv thread pool (never the event loop), two at a time, with limitInputPixels against a decompression bomb; the on-disk LRU cache under workspace/thumbs is bounded on both bytes and entries (THUMBS_MAX_MB=64, THUMBS_MAX_ENTRIES=4000) and evicts to 80% of whichever ceiling it hit (added 2026-09-07) |
projectRedirect | GET /api/files/project/:projectId | 302 redirect to the trailing-slash static-serve route (serveProject) (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
delete | DELETE /api/files?id= | removes a file/folder from disk + index; 403 on scope ‘user’ (read-only from the API) -> Ok (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
archive
| Operation | Route | Notes |
|---|---|---|
list | GET /api/archive?grouping=&kind=&projectId=&order=&limit=&q= | the shelves and the Timeline, pre-banded -> ArchivePage. grouping: gallery (Recent -> one shelf per project, finished runs before abandoned ones, newest first within each -> kind) | timeline (by day) | flat (the complete list, never capped: that is the Files view’s whole promise) |
search | GET /api/archive/search?q=&limit= | keyword (FTS5) ranked FIRST, then semantic (embeddings) -> ArtefactSearchResponse. semantic:false means the vector leg did not run (no key, or nothing embedded yet) and says so rather than silently returning less |
stats | GET /api/archive/stats | header counts, scoped to the viewer -> ArchiveStats |
get | GET /api/archive/:id | one artefact with its provenance resolved; 404 when it is not the caller’s -> Artefact. Touches lastOpenedAt |
thumbnail | GET /api/archive/:id/thumbnail?w= | a square webp of an IMAGE artefact, through the same bounded libvips path and on-disk LRU as GET /api/files/thumbnail -> image/webp. 415 {error:‘no_thumbnail’} for anything that cannot be drawn, which the Gallery answers with its type icon |
reconcile | POST /api/archive/reconcile | the REPAIR tool, not the mechanism: walk the caller’s own project directories and index what write-time hooks never saw -> ReconcileResult |
covers | GET /api/archive/covers | every one of the viewer’s projects with its shelf cover, or what one would be made of -> ArchiveCoversResponse {covers[], spentUsd, canGenerate}. READ ONLY: never builds, never spends, never opens a browser (added 2026-09-08) |
coverImage | GET /api/archive/covers/:id/image | the stored cover bytes for one project -> image/webp, ETag + private no-cache. 404 when none has been built, which the shelf answers with its typographic plate rather than an empty frame (added 2026-09-08) |
buildCover | POST /api/archive/covers/:id | BuildCoverRequest {force, allowModel} -> {cover: ArchiveCover, built, reason?}. The only route here that can spend: it prefers a REAL rendering (an image artefact’s bytes, or a Chrome screenshot of an HTML artefact, both free) and only asks the image model when the project made nothing anyone can look at. allowModel defaults FALSE, so the free button cannot become a paid one even when the page turns out to render blank. One project per call, never a batch, so a bill is approved item by item (added 2026-09-08) |
threads
| Operation | Route | Notes |
|---|---|---|
list | GET /api/threads?kind=&q= | the inbox -> ThreadSummary[] = Thread[] (incl. the promoted replyPolicy) + additive {participants[], unread, lastAt, lastAuthorId, lastPreview, aiPolicy (deprecated mirror of replyPolicy)}. The square thread is deliberately not listed: it has no membership or read marker to list it by (see the header of apps/api/src/modules/threads/routes.ts) |
create | POST /api/threads | {kind, title?, participantIds?, replyPolicy?} -> ThreadSummary. aiPolicy still accepted as the deprecated name for replyPolicy |
get | GET /api/threads/:id | -> Thread (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
messages | GET /api/threads/:id/messages | -> Message[] |
send | POST /api/threads/:id/messages | {content, attachments?, rounds?: 1-4, replyPolicy?} -> Message (user). Assistant streams via WS message.delta then message.created. aiPolicy still accepted as the deprecated name for replyPolicy |
nudge | POST /api/threads/:id/nudge | {openerId?, prompt?, rounds?: 1-4} -> {ok, threadId, openerId, rounds, mode: ‘open’|‘continue’} — carries an exchange on with NO human message: with a prompt the opener starts a line of talk in their own voice, without one the room answers what was said last. Owner only (turns are billed to thread.ownerId); 409 when the conversation’s replyPolicy is ‘none’; 429 {retryAfterMs} while an exchange is running or in its cooldown; 402 llm_budget when the budget is already spent; 503 llm_unavailable with no key. Turns arrive over WS (message.delta, message.created) exactly as a reply to a sent message does (added 2026-09-06) |
delete | DELETE /api/threads/:id | owner only: deletes the thread and its messages -> Ok (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts). Also forgets the room bound to it: channel binding, seats, sessions, meeting |
channel | POST /api/threads/channel | OpenChannelRequest {groupId, title?} -> ThreadSummary. Opens (or returns) the ONE conversation a crew has with itself — the route that finally creates a ThreadKind 'agent', which nothing in the tree had ever written. Idempotent per group; every member is seated when it opens, whoever they are; lead-or-owner only, because the conversation is billed to whoever opens it; its replyPolicy is none because the floor answers here, not dm.ts’s free-for-all (added 2026-09-06) |
join | POST /api/threads/:id/join | join a room you are entitled to be in -> ThreadSummary. A channel admits anyone of its crew; anything else admits only someone already a participant. Seats the caller, marks them present in the room, and marks the conversation read. Entitlement is derived from the session and the group, never from the request (added 2026-09-06) |
session | POST /api/threads/:id/session | RunRoomRequest {prompt?, openerId?, maxTurns?: 1-12, maxUsd?, floorPolicy?} -> RoomSession. Ask the room to talk: the caller supplies at most a topic and a ceiling, and the room decides who speaks each turn. Owner only (a meeting’s chair uses POST /api/meetings/:id/run); 409 while a stretch is already running; 402 llm_budget; 503 llm_unavailable; 400 when this conversation is not a room, or nobody in it can be spoken for. A prompt is written into the transcript under the CALLER’s name, never a citizen’s. Turns arrive over WS as message.delta/message.created, and the floor moves over room.updated (added 2026-09-06) |
sessionGet | GET /api/threads/:id/session | -> {session: RoomSession|null, floorBeingId, speakerIds[], presentBeingIds[]} — the running stretch or the last one, with what it used of its bound, whose turn it is, who may take one, and who is in the room. Presence is on this route as well as on the WS room.updated event because a client that has just arrived has missed every event so far: the event is the live update, this is the state (added 2026-09-06) |
sessionStop | DELETE /api/threads/:id/session | stop a running stretch; the loop notices before its next turn -> {ok, session} (added 2026-09-06) |
meetings
| Operation | Route | Notes |
|---|---|---|
list | GET /api/meetings | every meeting the caller convened or was seated in, newest first -> Meeting[] (added 2026-09-06) |
create | POST /api/meetings | CreateMeetingRequest {title, agenda?, participantIds?, groupId?, floorPolicy?, maxTurns?, maxUsd?} -> MeetingDetail {meeting, participants[], session, myRole}. The caller is always chair and owner: turns are billed to them. participantIds and groupId are REQUESTS — every id must be a real being, and a group is expanded only when the caller is of it (added 2026-09-06) |
get | GET /api/meetings/:id | -> MeetingDetail. Visible to its convener and to anyone seated in it; there is no “anyone with the link” (added 2026-09-06) |
join | POST /api/meetings/:id/join | arrive: marks the caller present IN THIS ROOM -> MeetingDetail. 403 for someone who was not invited (added 2026-09-06) |
leave | POST /api/meetings/:id/leave | step out: the seat and the turn count stay, only presence changes -> MeetingDetail (added 2026-09-06) |
addParticipant | POST /api/meetings/:id/participants | chair only. {beingId, role?: chair|speaker|observer} -> MeetingDetail. observer is how somebody sits in a room the world never hands the floor to, and it is a property of the seat (added 2026-09-06) |
removeParticipant | DELETE /api/meetings/:id/participants/:beingId | chair only -> MeetingDetail. 400 for the chair themselves (added 2026-09-06) |
run | POST /api/meetings/:id/run | RunRoomRequest -> RoomSession. Chair or owner only (billed to the owner). Same refusals as the thread session route; maxTurns and maxUsd may be lowered for one stretch and never raised above the meeting’s own (added 2026-09-06) |
stopRun | DELETE /api/meetings/:id/run | chair only: stop a running stretch -> {ok, session} (added 2026-09-06) |
close | POST /api/meetings/:id/close | {summarise?: boolean = true} -> MeetingDetail with minutes written. THE ARTEFACT: markdown with attendance and turns per participant, the turn and spend accounting against the bound, the whole transcript, and — best effort only — a summary, decisions and actions from one cheap model call over a FENCED transcript. No key, no budget or a failed summariser still closes the meeting and still writes minutes; it never fails to close because the summariser could not run. The minutes are also appended to the transcript as a system message, where they cannot be lost (added 2026-09-06) |
minutes | GET /api/meetings/:id/minutes | -> {meetingId, status, minutes: string|null}. Null until the meeting has closed (added 2026-09-06) |
spaces
| Operation | Route | Notes |
|---|---|---|
list | GET /api/spaces | every space the caller may enter — public ones, theirs, ones they are admitted to, ones their group may enter — Square first, then homes, then newest -> Space[] (added 2026-09-06, spaces/groups build) |
create | POST /api/spaces | CreateSpaceRequest {kind?, title, slug?, description?, visibility?, groupId?, parentId?, themeId?} -> SpaceDetail. Slug derived from the title when omitted, de-duplicated with -2/-3. visibility ‘group’ requires a groupId the caller is actually of (403 otherwise); parentId must be a top-level space the caller may enter (spaces nest one level, §1.2). The maker is admitted as its first member (added 2026-09-06) |
home | GET /api/spaces/home | the caller’s own home space — every being has one, created by the lazy backfill in modules/spaces/store.ts -> SpaceDetail (added 2026-09-06) |
get | GET /api/spaces/:id | by id OR slug -> SpaceDetail {space, members[], presentBeingIds[], threadIds[], canEnter, isMember}. 403 when the space is not open to the caller. GET /api/spaces/square is the Square as a space: public, the existing square thread as its stream, everyone online as its presence (added 2026-09-06) |
update | PATCH /api/spaces/:id | owner only. {kind?, title?, description?, visibility?, groupId?, themeId?} -> SpaceDetail. Turning a space group-visible requires a groupId the caller is of (added 2026-09-06) |
join | POST /api/spaces/:id/join | be in it: admits the caller when canEnter already says yes (public, or their group’s, or already admitted); 403 otherwise -> SpaceDetail (added 2026-09-06) |
leave | POST /api/spaces/:id/leave | gives up admission -> SpaceDetail. 400 for the owner, who deletes the space instead of leaving it (added 2026-09-06) |
delete | DELETE /api/spaces/:id | owner only -> Ok. 400 for a square or home space: the founding spaces are the world’s, not one being’s to unmake (added 2026-09-06) |
groups
| Operation | Route | Notes |
|---|---|---|
list | GET /api/groups | the groups the caller is of, or owns, newest first -> Group[] (added 2026-09-06, spaces/groups build) |
create | POST /api/groups | CreateGroupRequest {kind?, name, slug?, purpose?, members?: [{beingId, role?}], parentId?} -> GroupDetail. The maker is always seated lead; every named being is seated at the same moment, whoever they are. parentId puts the new group INSIDE another (a crew inside a company): 400 for a parent that does not exist or a placement past MAX_GROUP_DEPTH, 403 unless the caller also leads or owns the parent — containment is a claim about the parent too (parentId added 2026-09-06, group hierarchy) |
get | GET /api/groups/:id | by id OR slug -> GroupDetail {group, members[], spaceIds[], myRole, ancestors[], children[]}. 403 unless the caller is of it or owns it — unchanged: being of a PARENT does not admit you here, because Group.parentId is descriptive and grants nothing. members is and stays the DIRECT roster; ancestors (root first) and children are names and kinds only, never rosters (ancestors/children added 2026-09-06, group hierarchy) |
update | PATCH /api/groups/:id | leads only. {kind?, name?, purpose?, parentId?} -> GroupDetail. parentId: null makes it a root again. Moving it under another group needs lead-or-owner of BOTH ends and is refused (400) when the parent does not exist, is the group itself, is one of its own descendants (a cycle), or would push the subtree past MAX_GROUP_DEPTH (parentId added 2026-09-06, group hierarchy) |
addMember | POST /api/groups/:id/members | leads only. AddGroupMemberRequest {beingId, role?} -> GroupDetail. Seats a being, or changes the role of one already seated. Validates that the being exists and the role parses — and nothing else, because nothing about a being’s kind can make a seat inappropriate (added 2026-09-06) |
removeMember | DELETE /api/groups/:id/members/:beingId | a lead may unseat anyone; anyone may unseat themselves -> GroupDetail. 400 for the group owner, who disbands instead (added 2026-09-06) |
delete | DELETE /api/groups/:id | owner only: disbands -> Ok. Spaces that pointed at the group keep their rows, lose the pointer and fall back to private visibility; project_crews and group_holdings rows for it are dropped, so a project whose crew is disbanded assembles automatically again (added 2026-09-06) |
work | GET /api/groups/:id/work | -> GroupWork {groupId, seats[], projects[], contributions[], holdings[]}. What this crew has done: the reasoning episodes it was chosen for, what each seat did in them (tasks, done, cost), and what it works with. A read model over project_crews/projects/tasks/agents, stored nowhere. 403 unless the caller is of it. An agent that worked on a crew project without being seated or held appears as a contribution with role observer — a gap assembly had to fill, shown rather than absorbed (added 2026-09-06, crews build) |
holdings | GET /api/groups/:id/holdings | -> GroupHolding[] {groupId, kind, itemId, label, addedBy, addedAt}. The agents and generators the group works with. 403 unless the caller is of it (added 2026-09-06) |
addHolding | POST /api/groups/:id/holdings | leads only. AddGroupHoldingRequest {kind: ‘agent’|‘generator’, itemId} -> GroupHolding[]. 400 for an unknown kind or a missing row, 403 for something the caller does not own. This does NOT transfer ownership: agents.owner_id and generators.owner_id are untouched, so §5.1 rule 2 (“ownerId is always a being id”) stays literally true — a holding says reachable by this group (added 2026-09-06) |
removeHolding | DELETE /api/groups/:id/holdings/:kind/:itemId | leads only -> GroupHolding[]. Takes it off the shelf; the agent or generator itself is untouched (added 2026-09-06) |
treeMembers | GET /api/groups/:id/tree-members | -> GroupTreeMembers {groupId, groupIds[], members: [{beingId, groupId, role, depth, joinedAt}]}. “Everyone in this company”: the union of the DIRECT rosters of this group and every group beneath it, deduplicated by being, nearest group winning. Each row names the group the being is really of, so it can never be read as a membership of the group asked about. Lead-or-owner only — a stricter gate than the roster itself, because it reaches into groups the caller may not be of. NOTHING in the world’s authorisation path consults this: canEnter, listSpacesFor, listGroupsFor, roleOf and requireLead all still answer on direct membership only (added 2026-09-06, group hierarchy) |
crews
| Operation | Route | Notes |
|---|---|---|
get | GET /api/projects/:projectId/crew | -> ProjectCrewDetail {projectId, crew: ProjectCrew|null, group: Group|null, seats: CrewSeat[], drawnFromGroupIds: Id[]}. drawnFromGroupIds is just the bound group unless the binding sets includeDescendants, in which case it is that group and every group beneath it — stated rather than inferred, so a board can say whose people are about to be given work. Null crew means automatic assembly, which is every project that has not been given one. 404 for a project that does not exist, 403 for one that is not the caller’s (added 2026-09-06, crews build) |
set | PUT /api/projects/:projectId/crew | SetProjectCrewRequest {groupId: string|null, includeDescendants?: boolean} -> ProjectCrewDetail. Choosing replaces any earlier choice (one crew per episode); {“groupId”: null} clears it and restores automatic assembly. Caller must own the project AND be of the group (403 otherwise). Choosing starts nothing: the next run reads this row. includeDescendants (default false, and false for every binding made before 2026-09-06) lets assembly also draw on the seats of the groups INSIDE the bound one — opt-in per project because a company’s tree is a spend surface as much as a roster; setting it needs lead-or-owner of the bound group, not merely membership (added 2026-09-06) |
account
| Operation | Route | Notes |
|---|---|---|
export | GET /api/account/export | -> {manifest: AccountExportManifest, data: Record<table, rows[]>} as an attachment novaterra-<handle>-<date>.json. UK GDPR Art. 15 and Art. 20. Every row of every table that names the caller, plus the children hanging off them (a project’s tasks and traces, a thread’s messages, a listing’s reviews), found by DISCOVERING the schema at runtime rather than from a list that would silently go stale. Scoped from the session; there is no beingId parameter, deliberately. Credentials are withheld and each withholding is named in the manifest: password hash, session tokens, encrypted connection envelopes, private signing keys (added 2026-09-10) |
erase | DELETE /api/account | EraseAccountRequest {confirm, password} -> EraseAccountResponse {erasedAt, deleted[], kept[], notes[]}. UK GDPR Art. 17. confirm must be the caller’s own handle and the password is checked against the stored hash: a session cookie alone must not be able to destroy a world. 403 for the world OWNER with the reason — one node has exactly one owner (tests/db/single-owner-invariant.test.ts) and erasing them bricks the node rather than deleting an account. What survives is listed in kept with the lawful reason: accounting records under Companies Act 2006 s.388 and VAT Act 1994, and moderation records, per Art. 17(3)(b) and (e). Drops every session and clears the cookie (added 2026-09-10) |
identity
| Operation | Route | Notes |
|---|---|---|
me | GET /api/identity/me | the caller’s own global address -> IdentityStatus {beingId, did, chain: RotationChain, createdAt, hasSecret} (added 2026-09-06, Wave C) |
being | GET /api/identity/beings/:id | any being on this node, by local id or me -> IdentityStatus. An address is not a secret (added 2026-09-06) |
passport | GET /api/identity/beings/:id/passport | -> SignedPassport {payload: PassportDocument, signature: Signature}. The SAME bytes for every caller, which is what makes it shareable: PassportDocument is a strict shape with no vow, no mind page and no consent page — the private fields do not exist in it rather than being nulled per viewer (added 2026-09-06) |
chain | GET /api/identity/beings/:id/chain | -> RotationChain {origin, current, links: SignedRotation[]}. What a peer holding a stale address asks for (added 2026-09-06) |
book | GET /api/identity/beings/:id/book | -> PassportBook {document: SignedPassport, visa: PassportVisa|null, stamps: PassportStamp[], issuer: {did, label}, chainLinks}. The passport as the physical object at /passport: the SAME signed bytes as the route above, plus the two things a document that travels cannot carry. Viewer-dependent and therefore UNSIGNED — a stamp is evidence this node holds a row, not something a stranger can check, and signing a per-viewer document would dress a database read as a cryptographic fact. visa is the spent access code that admitted the being and is included ONLY when :id resolves to the session’s own being (not the owner’s: an operator minted the code and reading somebody’s arrival out of their passport is not an operator’s job); its code is null and only a hint is shown for the node’s standing bootstrap owner code, which ensureOwnerAccessCode() re-inserts at every boot and modules/admin/access.ts already redacts. stamps are connections (a real grant date) and sealed federation crossings (delivered/received rows with sealed=1, dated at the FIRST crossing per peer per direction) — nothing decorative, and no stamp for a queued or plaintext message (added 2026-09-10) |
sealingKey | GET /api/identity/beings/:id/sealing-key | -> SignedSealingKey {payload: SealingKeyDocument {v, type, did, alg: ‘x25519’, version, key, issuedAt, expiresAt}, signature}. Minted on first ask, exactly as the address is. Also carried inside PassportDocument.sealingKey (OPTIONAL, absent-never-null: a null default would change the canonical bytes of every passport already signed), so a peer normally learns it from the passport it already receives and never calls this route — it is here for catching up on a rotation without re-fetching a whole passport (added 2026-09-07) |
sealingKeyRotate | POST /api/identity/beings/:id/sealing-key/rotate | -> SignedSealingKey at version+1; the previous one is retired but stays OPENABLE for 30 days so in-flight and Hall-queued mail is not silently eaten. Self always; anyone else needs the node owner — an ownership check scoped from the session, never a branch on kind. Does NOT change the being’s address: a sealing-key compromise is not a signing-key compromise, and making every peer relearn who somebody is would be an expensive answer to a question nobody asked. The reverse coupling IS automatic and needs no route — a SIGNING rotation produces a new did, which has no sealing key, so the next passport mints one (added 2026-09-07) |
sealingKeysLearn | POST /api/identity/sealing-keys/learn | owner only. {document} -> SealingKeyLearnResult {did, outcome: accepted|already-known|superseded|refused, version, reason}. Kept only when signature.did === payload.did (a self-assertion, exactly like a passport — this is the impostor refusal) and the version is not below one already held (a signed document is replayable for ever, so monotonicity is the whole downgrade defence). Two different keys claimed at the SAME version is refused, not resolved: same class of fact as ChainLearnResult.forked (added 2026-09-07) |
resolve | GET /api/identity/resolve/:id | :id is a did:key -> {did, beingId, handle, displayName, current}. 404 for an address this node has never seen. 400 for a string that is not a did:key (added 2026-09-06) |
rotate | POST /api/identity/beings/:id/rotate | {reason?} -> {rotation: SignedRotation, chain: RotationChain, status: IdentityStatus}. Mints a successor, has BOTH keys sign the handover (the predecessor grants; the successor proves possession) and retires the old one. Self always; anyone else needs the node owner — an ownership check scoped from the session, never a branch on kind. 409 when this node no longer holds the private half (added 2026-09-06) |
verify | POST /api/identity/verify | {document} -> VerificationResult {valid, did, reason}. Takes a SignedPassport, a SignedRotation or a whole RotationChain from ANYONE and says whether it holds up, using only what is in the document — no lookup, no network. This is the check a peer will run in Wave D, callable today (added 2026-09-06) |
export | GET /api/identity/export | owner only -> IdentityExport {v, type, exportedAt, beings: [{localId, handle, chain, passport}]}. PUBLIC HALVES ONLY — no field of IdentityExport can hold a secret (added 2026-09-06) |
node | GET /api/identity/node | -> NodeIdentityStatus {did, label, profile, createdAt, hasSecret, chain, serves}. The NODE’s own address, which is not any being’s: a being’s key proves a document, and nothing proved the machine serving it (added 2026-09-07) |
nodeDescriptor | GET /api/identity/node/descriptor | -> SignedNodeDescriptor {payload: NodeDescriptor, signature}. Signed fresh per request, 15-minute expiresAt INSIDE the signed bytes. serves is a routing hint about where to ask, NEVER evidence about the beings in it — a node cannot say anything about a being, only the being’s own key can (added 2026-09-07) |
nodeRotate | POST /api/identity/node/rotate | owner only. {reason?} -> {rotation, chain, status, note}. Same counter-signed chain a being uses. Honestly weaker: peers know a node BY its address, so they must relearn it out of band, and until they do a thief holding the old key can serve them a chain omitting this rotation. note says so (added 2026-09-07) |
challenge | POST /api/identity/challenge | {audience?: Did|null} -> SignedChallenge. Mints a single-use nonce and signs a challenge around it. Freshness comes from the VERIFIER’s randomness, not from a clock: two nodes disagree about the time and that disagreement must not be load-bearing (added 2026-09-07) |
attest | POST /api/identity/attest | {challenge, document} -> SignedAttestation over {nonce, audience, subject: {type, did, alg, digest}}. Signed by the SUBJECT being’s key when this node holds it (“this document of mine is current”), by the node’s key otherwise (“I served these bytes just now”) — a weaker claim the verdict keeps separate. Never signs the caller’s bytes verbatim (added 2026-09-07) |
check | POST /api/identity/check | {document, attestation?} -> DocumentCheck {signature: VerificationResult, freshness: FreshnessVerdict, revocation: RevocationVerdict, trusted}. The three questions together. verify above is unchanged and still answers only the first (added 2026-09-07) |
chainsLearn | POST /api/identity/chains/learn | owner only. {chain, observations?: SignedObservation[]} -> ChainLearnResult {origin, outcome: accepted|extended|already-known|refused|forked, current, links, withheld, observations, reason}. A chain verifies on its own, so it can be learned from anywhere; withheld: true means a witness has seen MORE links than the chain just offered, which is the only way to catch a node serving a shortened history (added 2026-09-07) |
chainsKnown | GET /api/identity/chains/:id | :id is any did:key in the history -> KnownChain {origin, chain, source: local|learned|witnessed, learnedAt, observations}. 404 for an address this node knows nothing about (added 2026-09-07) |
witness | POST /api/identity/witness | owner only. {chain, via?} -> SignedObservation. This node signs what it saw: origin, current, link count, digest, its own clock. Detection, never consensus — two witnesses disagreeing is a fact for a person, not a vote (added 2026-09-07) |
revoke | POST /api/identity/beings/:id/revoke | {did?: Did|null, notValidAfter: ISODate, reason?} -> {revocation: SignedRevocation, rotated, chain, status}. “Stolen on Tuesday; distrust anything after Monday” — which rotation CANNOT say. Revoking the key still in use rotates first so the successor signs and the being is not left mute. Self always; anyone else needs the owner (added 2026-09-07) |
revocationsLearn | POST /api/identity/revocations/learn | owner only. {revocation} -> {learned, reason}. Kept only when signed by the key itself or by a successor proved through a rotation chain — anyone else’s is a stranger asking you to distrust somebody. The EARLIEST cutoff wins (added 2026-09-07) |
revocations | GET /api/identity/revocations | -> SignedRevocation[]. This node’s own and everything it has learned (added 2026-09-07) |
petnames | GET /api/identity/petnames | -> Petname[] {did, current, name, note, source, introducedAs, firstSeenAt, updatedAt}. What THIS node calls the addresses it knows. Local, unsigned, never exported, and deliberately NOT global discovery — OS-PLAN §5.3 says that decision is not made, and a name service shipped here would make it by accident (added 2026-09-07) |
petnameSet | POST /api/identity/petnames | owner only. {did, name, note?, source?, introducedAs?} -> Petname. One name means one address on this node: 409 with a free suggestion when the name is taken, never a silent ash2. The name follows the key through rotation, so a friend rotating does not become a stranger (added 2026-09-07) |
petnameForget | DELETE /api/identity/petnames/:id | owner only. :id is a did:key -> {did, removed}. Forgets the name; the address is untouched (added 2026-09-07) |
federation
| Operation | Route | Notes |
|---|---|---|
peers | GET /api/federation/peers | owner only -> Peer[] {nodeDid, endpoint, name, status: unverified|verified|unreachable|refused|blocked, label, profile, software, serves, addedAt, verifiedAt, lastSeenAt, collectedAt, relay, lastError}. label/profile/software are COPIES of what the peer last claimed about itself — advisory, never trust (added 2026-09-07). status:'unreachable' with verifiedAt:null is “that address has NEVER once answered”; with a date it is “answered before, asleep now” — the distinction that used to be invisible, and the reason no fifth PeerStatus value was added for it. collectedAt is the last time that peer COLLECTED from here (federation.ts §3c): a peer nothing can push to but which collects is exchanging messages perfectly well, and a collect deliberately leaves status alone because it proves the KEY is live and says nothing about the ADDRESS (added 2026-09-09) |
addPeer | POST /api/federation/peers | owner only. AddPeerRequest {nodeDid, endpoint, name?, relay?} -> PeerSession {peer, verified, reason, check: DocumentCheck|null, skewSeconds, at}. Adds and immediately tries the handshake. A peer that is asleep is still added, marked unreachable — refusing would mean you can only befriend somebody while their laptop is open (added 2026-09-07) |
greetPeer | POST /api/federation/peers/:id/greet | owner only. :id is the peer’s node did -> PeerSession. Try the handshake again after they woke up or the URL was fixed (added 2026-09-07) |
updatePeer | PATCH /api/federation/peers/:id | owner only. UpdatePeerRequest {endpoint?, name?, blocked?, publish?, relay?} -> Peer. relay is where to leave mail for them when the endpoint does not answer (§3d): a THIRD value from a person out of band, beside the address and the endpoint, and null removes it (added 2026-09-09). publish starts or stops sending this node’s Square to that peer and is OFF BY DEFAULT — adding a peer means “we can message each other”, not “everything anybody here says in public goes to your machine”; the exact mirror of SquareAdmission defaulting to refuse on the way in. A blocked peer is sent nothing whatever publish says (added 2026-09-07). The address is the identity and the endpoint is only where to knock, so a new URL is a MOVE and keeps a block. Unblocking returns it to unverified, never straight to verified |
forgetPeer | DELETE /api/federation/peers/:id | owner only -> {nodeDid, removed, threadsAffected}. The conversations STAY: what was said was said. threadsAffected names how many can no longer send (added 2026-09-07) |
conversations | GET /api/federation/conversations | -> FederatedConversation[] {threadId, peerDid, peerName, remoteDid, remoteCurrent, remoteHandle, petname, localBeingId, queued, stuck, createdAt}. The local half is an ordinary Thread of kind dm; the remote being has NO beings row on this node and never will (OS-PLAN §1.1) (added 2026-09-07) |
openConversation | POST /api/federation/conversations | OpenFederatedConversationRequest {peerDid, remoteDid, petname?} -> FederatedConversation. Both addresses from the owner; nothing is resolved or fetched. Scoped from the session — the body carries no local being id. 400 if remoteDid belongs to a being on this node (added 2026-09-07) |
thread | GET /api/federation/threads/:id | :id is the local thread id -> {conversation: FederatedConversation, messages: MessageProvenance[], wire: FederatedMessage[]}. Where each message got to, keyed by the local messages row — served BESIDE the transcript because Message has no federation field and is not getting one (added 2026-09-07) |
outbox | GET /api/federation/outbox?kind=post|retraction|message | owner only -> FederatedMessage[] {id, direction, kind, threadId, messageId, peerDid, authorDid, audienceDid, digest, sealed, state, attempts, nextAttemptAt, lastError, verified, createdAt, updatedAt}. One row per (document, peer): a Square post going to three peers is three rows sharing a digest, which is what makes “it reached two of your three” visible rather than inferred. kind defaults to post — the DM queue is read per conversation because a DM belongs to one, and a post belongs to none (added 2026-09-07) |
flush | POST /api/federation/flush | owner only -> CourierRun {attempted, delivered, unreachable, refused, at} & {requeued, collected, relayed}. The “they are back, go on then” button: requeues items that ran out of attempts, drains the outbox, THEN collects from every verified peer. collected rides beside requeued rather than inside CourierRun — a run of the outbox is what that schema describes, and a run of the inbox is a different fact (collect added 2026-09-09). It then asks this node’s RELAY, if it has one, and reports that separately as relayed — mail taken from a peer and mail taken from a relay reached this node by different routes with different freshness guarantees, and one number covering both would hide that (§3d, added 2026-09-09). The courier, the collector and the relay loop each also run on their own every 30s (added 2026-09-07) |
peerHello | POST /api/federation/peer/hello | PUBLIC. PeerHello {v, protocol, descriptor: SignedNodeDescriptor, challenge: SignedChallenge} -> PeerGreeting {v, protocol, descriptor, attestation, challenge, accepted, reason}. One round trip gives freshness BOTH ways: each side hands over a nonce with its descriptor, so neither has to trust a clock. accepted:false for a node this owner has not added — first contact is out of band and this node takes nothing from a stranger (added 2026-09-07) |
peerDeliver | POST /api/federation/peer/deliver | PUBLIC. {envelope: SealedEnvelope {v, type:‘novaterra.envelope.sealed’, protocol:2, descriptor, audience, seal:{v, alg, epk, ct}} | MessageEnvelope (protocol 1, plaintext)} -> DeliveryReceipt {accepted, digest, code: RefusalCode|null, reason, at, check}. FROM PROTOCOL 2 THE BODY IS ENCRYPTED: passport, message and attestation are sealed to the recipient’s own did:key with X25519 (derived from their Ed25519 address) + HKDF-SHA256 + AES-256-GCM, so a relay or a Hall that terminates TLS sees ciphertext. It is SIGN-THEN-ENCRYPT and the signature is what authenticates — opening a seal proves NOTHING about the sender, because anyone can encrypt to a public key. descriptor and audience stay in the CLEAR, so who is talking to whom, when and roughly how much are all still visible to a relay; there is no padding and no forward secrecy (a stolen recipient key opens every message ever sent to it). Protocol 1 plaintext is still ACCEPTED inbound and marked sealed:false on the provenance; it is never SENT — a peer too old to open a seal gets a stuck message and a sentence, never a silent downgrade (added 2026-09-07). ALWAYS HTTP 200, refusal included: a sender that cannot tell “your message is wrong” from “your request never arrived” retries the first for ever. Refuses cheapest-first — shape, protocol, which machine, whether we know it — and only then verifies four signatures. checkDocument() decides signature/freshness/revocation; this route adds the two equalities that matter (the passport must belong to the message’s signer; the attestation must be about this message’s digest) (added 2026-09-07) |
peerCollect | POST /api/federation/peer/collect | PUBLIC. PeerCollect {v, protocol, descriptor: SignedNodeDescriptor, attestation: SignedAttestation, challenges: SignedChallenge[1..8], ack: digest[0..32]} -> PeerCollection {v, protocol, node, envelopes: SealedEnvelope[], remaining, acked, accepted, reason}. THE DIRECTION A NODE BEHIND NAT CAN USE, and the Seed protocol’s answer (seed/beat calls in, authenticated purely by a signature) applied to peers: a machine with no inbound address cannot be pushed to, so it calls in, proves itself with its own signed descriptor plus an attestation over a nonce this node issued, and takes what is queued for it. THE QUEUE IS KEYED ON THE ADDRESS THAT JUST PROVED ITSELF AND ON NOTHING ELSE — there is no id in the body naming a queue, because this route hands out other people’s messages if that is ever got wrong. One challenge per envelope, minted audience: null for PeerGreeting.challenge’s reason (what comes back is signed by the BEING who wrote it, not by the node). AT-LEAST-ONCE: the responder marks a handed-over row sending, not delivered — a lost response must not lose somebody’s message — and the collector names the digests it took, or already had, in ack on its next collect. envelopes is typed SealedEnvelope[], so there is no shape in which a message leaves in the clear; a collector below protocol 2 gets an empty collection and a sentence. Refuses cheapest-first in peer/deliver’s exact order and rate-limit class — shape, protocol, which machine, whether we know it — and only then spends the nonce or builds a seal. A stranger is told exactly what peer/hello already tells it and NOTHING about a queue: it cannot learn whether an address is known here or whether anything is waiting. NOT A RELAY: only documents this node queued itself, for the one address that proved itself, and the collector refuses an envelope in the response signed by any other machine. DMs only — Square posts are unsealed and admitted by the moderation module, so a peer reachable only by collect gets its DMs and no posts (and publishPeerRows never queues posts at it, because a collect deliberately does not change a peer’s status). DOES NOT SOLVE two nodes both behind NAT: neither can dial the other. That case is the relay’s (§3d, /api/federation/relay below), and it is a DIFFERENT wire on purpose — this route hands over only documents this node queued itself, and never became a place that holds mail for somebody else (added 2026-09-09) |
relay | GET /api/federation/relay | owner only -> NodeRelayState {endpoint, relayDid, enrolled, enrolledAt, expiresAt, limits: RelayLimits|null, deposited, collected, lastError}. WHERE THIS NODE KEEPS ITS MAIL when nobody can dial it. endpoint comes from NOVA_RELAY_ENDPOINT and there is deliberately NO route that sets it: a relay is infrastructure a person chose, and a form that can point a node’s mail at a new machine is a form worth attacking. relayDid is PINNED on the first successful enrolment and a different address at the same URL is refused afterwards — an endpoint is where to knock, an address is who answers. Null relayDid with a set endpoint means enrolment has not worked yet and lastError says why (added 2026-09-09) |
relayEnrol | POST /api/federation/relay/enrol | owner only -> RelayEnrolment {v, relay, mailbox, limits, expiresAt, held, accepted, reason}. “I have just set this up, does it work”. Adds no capability — the relay loop calls the same function every pass — it only removes a wait. mailbox is always this node’s own address: the relay opens a mailbox for the key that proved itself and has no route that could open one for anybody else (added 2026-09-09) |
capabilities | GET /api/federation/capabilities | PUBLIC -> SignedCapabilityProfile {payload: {v, type, did, profile, runs: NodeFacility[], delegatesTo, fronts: Did[], seedProtocol, limits:{maxDocumentBytes, maxQueued}, issuedAt, expiresAt}, signature}. OS-PLAN §2’s table as something a node advertises, signed by the node key. A SELF-ASSERTION and routing only, never trust: a Seed claiming inference is lying and nothing can tell. Served BESIDE the node descriptor rather than inside it because SignedNodeDescriptor is .strict() and already on a live wire — a new field there costs a protocol bump, and a peer too old to know this route exists still has descriptor.payload.profile. greetPeer fetches it after a verified handshake and records it on the peer row (added 2026-09-07) |
seeds | GET /api/federation/seeds | owner only -> SeedPairing[] {did, beingId, name, profile, grants: (‘signals’|‘storage’)[], status: paired|blocked, seq, beats, acts, refused, pairedAt, lastSeenAt, lastNote, lastError}. seq is this node’s high-water mark and the only column a device’s own traffic moves (added 2026-09-07) |
pairSeed | POST /api/federation/seeds | owner only. PairSeedRequest {did, name, handle?, grants?} -> SeedPairing. Writes a beings row, a being_keys row with secret NULL, and the pairing, in ONE transaction. The address comes from a person out of band — there is no discovery, no mDNS and no “devices found on your network” (OS-PLAN §5.3). Grants default to NOTHING: a Seed with none still has presence and can ask for nothing (added 2026-09-07) |
updateSeed | PATCH /api/federation/seeds/:id | owner only. :id is the Seed’s did. UpdateSeedRequest {name?, grants?, blocked?} -> SeedPairing (added 2026-09-07) |
unpairSeed | DELETE /api/federation/seeds/:id | owner only -> {did, removed, beingId}. The BEING STAYS: what it said was said, and its Signals are on the owner’s desktop. Unpairing is also the documented recovery for a device whose flash was erased and whose counter went backwards — an owner action on purpose, because a Seed that could reset its own high-water mark would have no offline replay protection (added 2026-09-07) |
seedKnock | POST /api/federation/seed/knock | PUBLIC. {v:1, protocol, did} -> SeedGreeting {v, protocol, node, nonce, seq, grants, accepted, reason}. The ONE unsigned document in the Seed protocol, and it has to be: aud lives inside the signed bytes of everything else, so a device cannot sign until it has been told the address to name. For an unpaired address: accepted:false, no usable nonce, no grants, seq:0 (added 2026-09-07) |
seedBeat | POST /api/federation/seed/beat | PUBLIC. SignedSeedBeat {payload:{v, type:‘novaterra.seed.beat’, did, aud, seq, nonce, note}, signature} -> SeedReceipt {accepted, code: SeedRefusalCode|null, reason, basis: ‘nonce’|‘sequence’|‘none’, seq, nonce, at}. ALWAYS HTTP 200. Presence: the most it can cause is a counter moving and a note recorded. The receipt carries the nonce for the NEXT document, so a Seed’s steady state is one round trip per beat — which matters because a round trip is a radio wake (added 2026-09-07) |
seedAct | POST /api/federation/seed/act | PUBLIC. SignedSeedDelegation {payload:{…, capability: CapabilityKind, method, args: string[≤3]}, signature} -> SeedReceipt. THE BROKER OVER THE WIRE (OS-PLAN §1.4): the same isModuleBridgeMethod allowlist a module in an iframe goes through, with the caller identified by a signature instead of a session cookie. SEED_DELEGABLE_CAPABILITIES (signals, storage) is a STRICT SUBSET of MODULE_BRIDGE_CAPABILITIES — a Seed cannot do more by being remote than a local plugin can, pinned by tests/seed/delegation.test.ts. network is withheld: a module names its hosts in a manifest a person read, a Seed has no manifest, so granting it would mean a general-purpose proxy out of the owner’s home. A Seed’s Signal lands on the OWNER’s desktop and it cannot say whose — there is no field in which it could (added 2026-09-07) |
directory
| Operation | Route | Notes |
|---|---|---|
status | GET /api/directory/status | owner only -> DirectoryStatus {configured, endpoint, directoryDid, nodeDid, registered, advertisedEndpoint, handles, registeredAt, expiresAt, lastError, creditsClaimed}. directoryDid is PINNED on first successful registration: a directory that changes its address afterwards is a different directory and is refused, not followed (added 2026-09-07) |
register | POST /api/directory/register | owner only. DirectoryPublishRequest {endpoint, handles?} -> DirectoryRegistrationReceipt {accepted, did, reason, expiresAt, contestedHandles}. Answers the directory’s nonce with an attestation signed by THIS NODE’S key, so the service cannot write a record for a key nobody proved. contestedHandles reports other nodes already claiming a name — a fact, not a refusal (added 2026-09-07) |
withdraw | DELETE /api/directory/register | owner only -> {removed: boolean, reason}. Take this node’s address out of the directory. Proves the key the same way registering did: nobody but the holder can withdraw a record, and the directory cannot withdraw one on anyone’s behalf (added 2026-09-07) |
lookup | GET /api/directory/lookup?q= | owner only. q is a did:key:… or a claimed handle -> DirectoryLookup {query, kind, answeredBy, answerVerified, contested, hits: DirectoryHit[], note}. EVERY HIT CARRIES THIS NODE’S OWN VERDICT: trusted is false for a record whose registration is not a self-assertion by the address asked about, whose named directory is not the one asked, or whose descriptor belongs to somebody else. An untrusted hit is RETURNED with its reason rather than dropped — a tampered answer must not look like an absent one. Nothing here adds a peer; connecting is still POST /api/federation/peers with an address a person chose (added 2026-09-07) |
claimCredits | POST /api/directory/credits/claim | owner only -> DirectoryClaimResult {posted, credits, alreadyHeld, refused, remaining, balance}. THE NODE ASKS; THE DIRECTORY NEVER PUSHES (OS-PLAN §5.2). Credit bought while this node was asleep sits queued until this call. Each grant is posted to the append-only ledger with idemKey dir_grant:<id>, so the protocol is at-least-once and the effect is exactly-once — alreadyHeld counts the difference and is not an error (added 2026-09-07) |
moderation
| Operation | Route | Notes |
|---|---|---|
blocks | GET /api/moderation/blocks | -> Block[] {id, beingId, subjectKind: being|address, subjectId, subjectLabel, reason, createdAt}. The CALLER’S OWN list; there is no way to read anyone else’s (added 2026-09-07) |
block | POST /api/moderation/blocks | CreateBlockRequest {beingId?|did?, reason?} -> Block. Exactly one of beingId/did. SYMMETRIC in reach: their posts, dms and presence stop reaching you and yours stop reaching them. Survives rotation — a local being is blocked by local id (being_keys joins every address they ever held to it) and an address is resolved across its whole rotation chain. A did belonging to a being on this node is normalised to that being’s id, so one person is one row however they were named. No kind branch: an AI citizen is blocked exactly as a human is (added 2026-09-07) |
unblock | DELETE /api/moderation/blocks/:id | -> Ok. Scoped to the caller’s own rows; somebody else’s block id is a 404, not a 403 (added 2026-09-07) |
report | POST /api/moderation/reports | CreateReportRequest {subjectKind: post|being|address, subjectId, category: illegal|abuse|spam|impersonation|other, note?} -> ReportReceipt {report, reach: owner|local-only, message}. A RECORD, not an action. 409 if you already have an open report on the same subject. The snapshot of what was said is stored WITH the report, because a report about content that has since been edited or deleted is unanswerable (added 2026-09-07) |
reports | GET /api/moderation/reports?state=open|upheld|dismissed | owner only -> ReportItem[] {report, content (the post as it stands NOW, null if gone), hidden, blockedByReporter} (added 2026-09-07) |
reportDecide | POST /api/moderation/reports/:id/decide | owner only. ReportDecisionRequest {decision: uphold|dismiss, note?, hide?, block?} -> ReportItem. Upholding is a FINDING; hide and block are what to do about it and are separate because a post can be wrong without its author needing cutting off. hide keeps the row as evidence and takes it out of every Square read (added 2026-09-07) |
queue | GET /api/moderation/queue | owner only -> ModerationSummary {openReports, heldPosts, hiddenPosts, blocks, policy} (added 2026-09-07) |
held | GET /api/moderation/held?state=held|admitted|refused|withdrawn | owner only -> HeldPost[] {id, digest, authorDid, authorCurrent, authorHandle, petname, peerDid, peerName, body, issuedAt, state, reason, check, heldAt, decidedBy, decidedAt, messageId, createdAt}. A held post is NOT in messages — the marketplace’s in_review trick, bought more cheaply: the Square reads messages, so there is no filter to forget (added 2026-09-07) |
withdrawPost | DELETE /api/moderation/posts/:id | :id is the square message id. The author, or the node owner -> PostWithdrawal {messageId, hidden, askedPeers, message}. Takes the post out of every Square read here (hidden, evidence row kept, exactly as an upheld report’s hide) and QUEUES a signed retraction for every peer that took delivery of it; copies still in the outbox are dropped rather than chased. askedPeers counts REQUESTS QUEUED and is named asked on purpose — a remote node is not obliged to comply, may already have shown it to everyone, and this node cannot tell whether it did. 400 for a post that arrived from another node: this node did not publish it and holds no key that could sign a withdrawal for it (added 2026-09-07) |
heldDecide | POST /api/moderation/held/:id/decide | owner only. HeldPostDecisionRequest {decision: admit|refuse, note?, block?} -> HeldPost. Admitting writes it into the Square as an ordinary message whose authorId is the author’s GLOBAL ADDRESS — same choice federation made, for the same reason: there is no beings row for a remote author and null would render their words as the reader’s own (added 2026-09-07) |
policy | GET|PUT /api/moderation/policy | owner only -> SquarePolicy {admission: refuse|review|petnamed|verified, perPeerHourlyLimit, updatedAt}. PUT body SquarePolicyRequest {admission?, perPeerHourlyLimit?}. DEFAULT IS refuse: shipping this code must not make a node start accepting posts from other machines. Everything above refuse sends what it will not admit to the owner’s queue rather than dropping it — a silently discarded post is indistinguishable from a network failure and the sender retries it for ever (added 2026-09-07) |
square
| Operation | Route | Notes |
|---|---|---|
retract | POST /api/square/retract | PUBLIC. {envelope: SquareRetractionEnvelope {descriptor, retraction: SignedSquareRetraction {payload:{v, type:‘novaterra.retraction’, did, postDigest, issuedAt}, signature}, attestation, chain?}} -> SquareRetractionReceipt {accepted, digest, postDigest, outcome: withdrawn|already-gone|unknown-post|refused, code, reason, at}. ALWAYS HTTP 200. Authorisation is a SIGNATURE and nothing else: the withdrawal must be signed by an address the post’s author has held, resolved across the rotation chain, so no node can retract its beings’ posts and no being can retract another’s. This node grants it (hidden, evidence row kept, square_held.state -> withdrawn) and that is THIS node’s policy, not an obligation — a receiving node is free to ignore a retraction and the sender cannot tell whether it did. No passport: the receiver already holds the post filed under an author address, so the only question is whether the signer is that address (added 2026-09-07) |
deliver | POST /api/square/deliver | PUBLIC. {envelope: SquarePostEnvelope {descriptor, passport, post, attestation, chain?}} -> SquarePostReceipt {accepted, held, digest, code: PostRefusalCode|null, reason, at, check}. ALWAYS HTTP 200. A SquarePostDocument is a second document type, not a widened MessageDocument: a post has no audience, and making the message wire’s recipient check conditional would be the cheapest way to eventually skip it. held: true is the third answer — the bytes verified and nobody has looked yet — so a sender does not retry something sitting in a queue. Refuses cheapest-first (shape, protocol, which machine, whether we know it, the per-peer hourly bound) before verifying four signatures. The optional chain is the author’s rotation history: an honest sender includes it and it is what makes a block outlive a rotation; one who omits it arrives as an address with no history, which no policy above verified admits (added 2026-09-07) |
desktop
| Operation | Route | Notes |
|---|---|---|
widgets | GET|PUT /api/desktop/widgets | PUT replaces layout: Widget[] |
themes | GET|POST /api/desktop/themes | -> Theme[] |
themeActivate | POST /api/desktop/themes/:id/activate | |
themeDelete | DELETE /api/desktop/themes/:id | removes a non-active theme -> Ok (added 2026-09-05, catalogued 2026-09-06) |
signals | GET /api/desktop/signals?status= | -> Signal[] |
signalRaise | POST /api/desktop/signals | Signal minus {id,beingId,createdAt} -> Signal — server-internal (signal.raise skill), no client call (added 2026-09-05, catalogued 2026-09-06) |
signalAnswer | POST /api/desktop/signals/:id/answer | {optionId|answer} -> Signal |
signalDismiss | POST /api/desktop/signals/:id/dismiss | |
signalFewerLikeThis | POST /api/desktop/signals/:id/fewer-like-this | dismisses the signal (if still open) AND writes a real preference Memory tagged with the nudge’s topic (apps/api/src/twin/hush.ts’s recordFewerLikeThis), so evaluateNudge() suppresses that exact topic — and, after repeated feedback, that whole kind of nudge — from here on. This is what makes “fewer like this” change future behaviour, not just clear one card -> Ok (catalogued 2026-09-06 — calendar/to-do/Hush build) UPDATED 2026-09-07: the topic key now comes from the hub’s action.hush.key stamp rather than being re-derived from the title at press time (a reworded title would otherwise file the feedback under a topic the gate never checks - a control that looks like it worked and does nothing), and the signal’s source is tagged too, so pressing this twice on Gmail cards turns Gmail down instead of suppressing unrelated topics one at a time. Enforcement moved with it: gateAtHub() in raiseSignal applies this to EVERY proactive caller, not only the two that called evaluateNudge |
now | GET /api/desktop/now | -> NowResponse |
omni | POST /api/desktop/omni | OmniRequest -> OmniResponse |
presence | GET /api/desktop/presence | who is online right now, from the WS presence map -> Array<{beingId, status: ‘online’|‘away’, doing?, since, tabs, lastSeen}> (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
studio
| Operation | Route | Notes |
|---|---|---|
generators | GET|POST /api/studio/generators | GET -> GeneratorSummary[] (the card grid’s fields; steps and inputsSchema were 95% of it). POST -> Generator |
generator | GET|PUT|DELETE /api/studio/generators/:id | GET -> the whole Generator. The run modal and the builder fetch this for the one they are about to act on |
projects | GET|POST /api/studio/projects | POST CreateProjectRequest -> Project (status planning) and starts the run |
project | GET /api/studio/projects/:id | -> ProjectDetail |
projectCancel | POST /api/studio/projects/:id/cancel | |
projectTrace | GET /api/studio/projects/:id/trace | -> TraceEvent[] |
projectResume | GET /api/studio/projects/:id/resume | what continuing would re-run, keep and hold, without doing it -> ResumePlan |
projectContinue | POST /api/studio/projects/:id/continue | pick a stopped project back up; 409 with a readable reason when it cannot -> {started, plan: ResumePlan, project: ProjectDetail} |
task | PATCH /api/studio/projects/:id/tasks/:taskId | {status?, assignee?: ‘being:<id>’|‘agent:<id>‘|null, dependsOn?} -> ProjectDetail. 409 while a run holds the project; 400 for a dependency loop or a step held behind an unanswered gate |
taskGate | PUT|DELETE /api/studio/projects/:id/tasks/:taskId/gate | PUT {question?} arms a human decision in front of this step’s dependants -> TaskGate; DELETE drops it -> Ok. The decision itself is an ordinary decision Signal answered at POST /api/desktop/signals/:id/answer |
portfolio | GET /api/studio/portfolio | every project of the caller’s at once, grouped by what it needs from them -> Portfolio. Scoped to request.being.id exactly like GET /api/studio/projects |
queue | GET /api/studio/queue | every open step across every project of the caller’s, one list, owner and sparks together -> WorkQueue |
projectDue | PUT /api/studio/projects/:id/due | SetDueRequest {taskId?: Id|null, dueAt: ISODate|null} -> DueDate. Target date for the project, or for one of its steps; null clears it |
agents | GET|POST /api/studio/agents | GET -> AgentSummary[] (the crew shelf’s fields; systemPrompt was 91% of it). POST -> Agent |
agent | GET|PUT|DELETE /api/studio/agents/:id | GET -> the whole Agent, including systemPrompt. The edit form fetches this for the one being edited |
skills | GET /api/studio/skills | -> SkillSummary[] (the shelf’s card fields; inputSchema + outputSchema were 73% of it — 47.8 kB. Trimmed 2026-09-07) |
skill | GET /api/studio/skills/:name | -> the whole SkillDef, schemas and all. The try-it drawer fetches this for the one skill it is opening, the way the run modal fetches one generator. :name is the dotted skill name (‘web.search’), not an id — that is what every caller already holds. Scoped through the same visibility filter as the list, so a private skill stays private (added 2026-09-07) |
skillInvoke | POST /api/studio/skills/invoke | SkillInvokeRequest -> SkillInvokeResponse |
market
| Operation | Route | Notes |
|---|---|---|
listings | GET|POST /api/market/listings?kind=&q= | -> Listing[]. kind is ListingKind, which since 2026-09-08 has role and hire where it had agent (§5.6.1 V7). POST refuses kind: 'hire' with 400: a hire is a citizen’s time and needs their consent and a scope, and neither exists yet — see ListingKind in contracts/market.ts |
listing | GET|PUT|DELETE /api/market/listings/:id | PUT refuses a change of kind to hire for the same reason POST does |
buy | POST /api/market/listings/:id/buy | -> Order |
orders | GET /api/market/orders | -> Order[] |
wallet | GET /api/market/wallet | -> Wallet |
walletLedger | GET /api/market/wallet/ledger?limit= | the wallet with its receipts, newest first, straight from the credit ledger -> {wallet: Wallet, entries: CreditEntry[]} (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
orderLedger | GET /api/market/orders/:id/ledger | both sides of one order’s money; buyer, seller or owner only -> {order: Order, entries: CreditEntry[]} (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
reviews | GET|POST /api/market/listings/:id/reviews | -> Review[] |
reviewsDelete | DELETE /api/market/listings/:id/reviews | removes the caller’s own review, recomputes rating -> Ok (added 2026-09-05, catalogued 2026-09-06) |
mine | GET /api/market/mine | seller dashboard -> SellerDashboard (additive) |
purchases | GET /api/market/purchases | what I have bought/been granted -> Purchase[] (additive) |
refund | POST /api/market/orders/:id/refund | owner only. {reason, revokeDelivery?} -> Order — a ledger REVERSAL (two new refund_debit/refund_credit entries), never an edit of the original sale entries; buyer credit is idempotent on refund:<orderId> (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
declaration | GET /api/market/listings/:id/declaration | what a module listing’s package declares, derived from its manifest by this server -> ModuleDeclaration. 404 for any other kind. Shown before purchase; the buyer still meets the grant screen afterwards (added 2026-09-06) |
queue | GET /api/market/queue?state= | owner only. The review queue: every module listing on this node with its moderation state and what it declares -> ModerationItem[]. A module listing is created in_review and is invisible to the listings query until a decision moves it (added 2026-09-06) |
queueDecision | POST /api/market/queue/:id/decision | owner only. ModerationDecisionRequest {decision: approve|reject, note} -> ModerationItem. Approve makes the listing active; reject leaves it in_review and records why (added 2026-09-06) |
lineage | GET /api/market/lineage/:id | id is the DELIVERED thing’s id (a cloned generator/agent/theme, or a staged module directory) -> LineageView {lineage, listing, latestVersion, updateAvailable}. Caller must own it. 404 when it was not bought here, which is how an original is told from a fork (added 2026-09-06) |
llm
| Operation | Route | Notes |
|---|---|---|
usage | GET /api/llm/usage | -> LlmUsageDetail (own totals/today/byModel/byTier/byDay + budget/budgets/configured) |
models | GET /api/llm/models | -> ModelsResponse (models, tiers, budget) |
wallet | GET /api/llm/wallet | -> LlmWallet, wallet-widget shape {configured, keyMasked (owner only), spent/my Today|Total, limits, remaining, usedFraction, exhausted, callsToday, lastCallAt, sparkline[7]} (added 2026-09-05, catalogued 2026-09-06) |
budget | GET /api/llm/budget?purpose=&projectId= | pre-check before an expensive action -> {allowed, reason, scope, remaining, spent, limits, configured} (added 2026-09-05, catalogued 2026-09-06) |
key | GET|POST|DELETE /api/llm/key | owner only, and about the NODE’s OWN key — never a citizen’s (see below). GET -> NodeKeyStatus {configured, keyMasked, inForce: ‘env’|‘stored’|‘none’, envPresent, storedPresent, shadowed, label, storedAt}; POST SetNodeKeyRequest {key} verifies against OpenRouter’s free /auth/key, stores it AES-256-GCM ENCRYPTED in the database (the owner’s connections row, provider llm.openrouter) and reloads the llm singleton -> NodeKeyStatus; DELETE clears the stored key -> NodeKeyStatus. It used to call writeEnvValue() to put the key in <repo root>/.env, which in the container is /app — NOT a volume — so a redeploy discarded it and process.env silently reverted to the platform secret while every response still said configured: true. inForce/envPresent/storedPresent/shadowed are four facts instead of that one boolean so the swap is visible. The key never appears in any response, log line or /api/health; tests/llm/no-key-leak.test.ts walks every one of them (added 2026-09-05, catalogued 2026-09-06, encrypted+persisted 2026-09-10) |
access | GET /api/llm/access | any signed-in being -> LlmAccess {canSpend, spending: ‘node’|‘local’|‘none’, isOwner, membersMaySpendNodeKey, nodeKeyConfigured, freeTiers, headline, bringYourOwnKeyAt}. Whose money a call would spend, answered for the CALLER. Scoped from the session by OWNERSHIP, never by being.kind (tests/contracts/citizenship.test.ts). There is deliberately no per-being key to report: PROFILE_FACILITIES gives inference to a home node and not to a hall, so a citizen’s own key lives on their own node and this one has no column for it (added 2026-09-10) |
policy | PUT /api/llm/policy | owner only. SetLlmPolicyRequest {membersMaySpendNodeKey} -> LlmSpendPolicy. DEFAULTS TO FALSE: before this, every signed-in being’s calls were billed to the owner’s key with nobody having chosen that. Enforced as a beforeCall guard, so it covers every call path (complete/stream/embed/media) rather than the routes somebody remembered. Free (local) calls are never refused by it — refusing a call that costs nothing for lack of dollars is the failure local-first exists to end (added 2026-09-10) |
inference | GET /api/llm/inference | PLAN §7b Settings -> Inference panel, read-only, any signed-in being -> InferenceSnapshot (per-tier provider resolution; baseUrl only, never a key) (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
inferenceDetect | GET /api/llm/inference/detect | owner only. Probes an OpenAI-compatible /models endpoint -> LocalModelsResult. Always probes the server’s OWN configured LOCAL_LLM_BASE_URL (Ollama default otherwise); a client-supplied baseUrl is deliberately ignored to close an SSRF vector (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
inferencePolicy | PUT /api/llm/inference/policy | owner only. SetNodeInferencePolicyRequest {tiers: [{tier, provider: ‘openrouter’|‘local’, model}], localBaseUrl} -> NodeInferencePolicy. The missing half of inferenceDetect: detection worked and nothing could WRITE a configuration, so using the home box meant hand-editing .env and restarting, which is why local-first was a fallback nobody reached. Stored in the database on the volume. THE ENVIRONMENT STILL WINS — LLM_PROVIDER_<TIER>/LOCAL_LLM_* override this, and stored values only fill a gap env left silent (same precedence as config.ts’s persistedSecret). provider:'local' with no model is REFUSED at the edge rather than stored, because resolveTier() treats that combination as “still going to OpenRouter” and a preference that silently routes remote is CLAUDE.md invariant 5 broken. There is no stored LOCAL_LLM_FALLBACK: spillover stays an operator decision on the machine (added 2026-09-10) |
admin
| Operation | Route | Notes |
|---|---|---|
people | GET /api/admin/people | owner only -> AdminPerson[]. canSuspend/cannotSuspendReason are server-computed from whether the being HOLDS CREDENTIALS, never from its kind (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
person | GET /api/admin/people/:id | owner only -> AdminPersonDetail: one being’s spend, memories, storage, wallet, activity, projects and threads. Computed identically for every being (added 2026-09-06) |
suspend | POST /api/admin/people/:id/suspend | owner only. {reason?} -> Ok. Any being that holds credentials; the owner cannot be suspended; destroys every session for that being (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
restore | POST /api/admin/people/:id/restore | owner only -> Ok. 400 if that being is not currently suspended (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
processes | GET /api/admin/processes | owner only -> AdminProcesses {instanceId, host, pid, bootedAt, runs, loops, reaper, approvals} (added 2026-09-06) |
cancelRun | POST /api/admin/processes/:id/cancel | owner only -> {ok, projectId, aborted, status, detail}. aborted is false when the holder is another process: the row flips now, the work stops at its next check or when the reaper sweeps it (added 2026-09-06) |
spend | GET /api/admin/spend?days= | owner only -> AdminSpend {budget, limits, byDay, byTier, byModel, byPurpose, byBeing, byProject, failed, recent} — LLM cost in USD, world-wide (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
budget | GET|PUT /api/admin/budget | owner only -> AdminBudgetCaps. PUT takes AdminBudgetCapsUpdate and MERGES key by key: an absent cap is untouched, null hands it back to .env, ‘off’ means no limit. Applies to the next call; no restart (added 2026-09-06) |
ledger | GET /api/admin/ledger?limit= | owner only -> AdminLedger {entries, totals, drifts, payments}. READ ONLY: credit_entries is append-only and nothing here writes to it. Stripe events carry type/outcome/time, never the payload (added 2026-09-06) |
health | GET /api/admin/health | owner only -> AdminSystem: the health report plus what only the filesystem knows — free disk, the database WITH its WAL and freelist, workspace size, table row counts, and named warnings (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
modules | GET /api/admin/modules | owner only -> AdminModule[]: every installed plugin with what its manifest USES beside what the install GRANTED, its extensions, skills and stored bytes (added 2026-09-06) |
moduleRevoke | POST /api/admin/modules/:id/revoke | owner only. {capability} -> {ok, capability, before, after, unloaded}. Fail-closed: narrows the grant row, unloads the plugin’s live skills, drops the install to needs-approval (added 2026-09-06) |
moduleUninstall | DELETE /api/admin/modules/:id | owner only -> {ok, removedDir, skills, storageRows} (added 2026-09-06) |
skills | GET /api/admin/skills | owner only -> AdminSkills {skills, counts, decisions}: the live registry, which entries are gated by WORLD_ACTING_SKILLS, and the approval decisions made about them (added 2026-09-06) |
access | GET /api/admin/access | owner only -> AdminOwnerAccess: whether the owner code is still the shipped default, where it comes from, a prefix hint. NEVER the value (added 2026-09-06) |
accessRotate | POST /api/admin/access/rotate | owner only. {confirm:‘rotate’} -> AdminOwnerAccessRotated. Mints a new owner bootstrap code, withdraws the old one if unused, and returns the new value ONCE. Never retrievable again (added 2026-09-06) |
audit | GET /api/admin/audit?limit=&offset=&actorId=&action=&q=&since=&until= | owner only -> AdminAudit {entries, total, actors, actions}. Append-only; facets are computed over the whole table so a filter can always be undone (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
payments
| Operation | Route | Notes |
|---|---|---|
webhooks | POST /api/payments/webhooks/:provider | UNAUTHENTICATED, raw-body HMAC-verified (provider: ‘manual’|‘stripe’; ‘manual’ has no webhooks and always rejects — see provider.ts). Always replies 200 once signature-verified, including for events it ignores, so the provider does not retry -> {received: true, duplicate, outcome, intentId} (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
packs | GET /api/payments/packs | the credit-pack price list, safe for anyone signed in -> {currency, merchant, packs: CreditPack[], providers, stripePublishableKey, limits: PaymentLimits} (limits added 2026-09-08 so a non-owner can be told the payout floor BEFORE the 400, not by it) (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
topups | GET|POST /api/payments/topups | GET -> PaymentIntent[] (caller’s own). POST {packId?|credits?, provider?, note?} -> {intent, checkoutUrl, clientSecret, instructions}; a manual top-up raises a proactive Signal to the OWNER, who is the only thing that can settle one (catalogued 2026-09-06; owner signal added 2026-09-08; surfaced by tests/contracts/routes.test.ts) |
topup | GET /api/payments/topups/:id | caller’s own top-up, or any if owner -> PaymentIntent (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
topupCancel | POST /api/payments/topups/:id/cancel | caller’s own, or owner -> PaymentIntent (409 if already settled) (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
topupGrant | POST /api/payments/topups/:id/grant | owner only, MANUAL top-ups only (never a Stripe intent) -> PaymentIntent (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
grant | POST /api/payments/grant | owner only. {beingId, credits, memo?, idemKey?} -> CreditEntry — a direct grant/adjustment with no intent behind it (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
ledger | GET /api/payments/ledger?beingId=&limit= | caller’s own ledger, or any being’s if owner -> {wallet: Wallet, entries: CreditEntry[]} (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
reconcile | GET /api/payments/reconcile | owner only: proves the fast wallet balance still matches the authoritative ledger sum -> {ok: boolean, drifts} (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
reconcileRepair | POST /api/payments/reconcile/repair | owner only: rewrites any drifted wallet balance from the ledger -> {repaired} (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
status | GET /api/payments/status | owner only: the payment plumbing’s own health — whether keys are set, never their values -> {currency, merchant, stripe: {configured, secretKeySet, webhookSecretSet, publishableKeySet, automaticTax, flow, webhookUrl}, limits: PaymentLimits, intents, events, payouts} — intents is EVERYONE’s, and a pending manual one is a person waiting on the owner (catalogued 2026-09-06; limits added 2026-09-08; surfaced by tests/contracts/routes.test.ts) |
payouts | GET|POST /api/payments/payouts | GET ?all= (owner only; otherwise caller’s own) -> PayoutRequest[]. POST {credits, note?} -> PayoutRequest — holds the credits out of the spendable balance immediately, before owner approval, and raises a proactive Signal to the OWNER (catalogued 2026-09-06; owner signal added 2026-09-08; surfaced by tests/contracts/routes.test.ts) |
payoutApprove | POST /api/payments/payouts/:id/approve | owner only. {decision?} -> PayoutRequest — marks paid IN CREDITS ONLY; no real money moves (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
payoutReject | POST /api/payments/payouts/:id/reject | owner only. {decision?} -> PayoutRequest — releases the held credits back with a reversal entry (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
payoutCancel | POST /api/payments/payouts/:id/cancel | the requester withdrawing their own request -> PayoutRequest — releases the held credits back (catalogued 2026-09-06; surfaced by tests/contracts/routes.test.ts) |
plugins
| Operation | Route | Notes |
|---|---|---|
list | GET /api/plugins | -> InstalledPluginView[] {installation, skills[], generators[], tier} — what is installed and what each one is contributing right now (added 2026-09-06) |
available | GET /api/plugins/available | owner only. Reads every directory under <workspace>/plugins WITHOUT importing any of their code -> {dir, candidates: PluginCandidate[], risk, enforcement} — the data the install/grant screen renders (added 2026-09-06) |
install | POST /api/plugins | owner only. InstallPluginRequest {dir, granted[]} -> InstalledPluginView. Refuses (400) when the manifest declares a capability the human did not grant, BEFORE the plugin’s module body runs (added 2026-09-06) |
uninstall | DELETE /api/plugins/:id?data=keep|delete | owner only. id is the PluginInstallation id, not the ‘@author/name’ plugin id (a slash cannot ride in a path param). data is REQUIRED and has no default: module_storage holds what the module saved and neither answer may be assumed. Unregisters its skills and generators, deletes its rail entries/routes and any placed widgets, revokes the grant in full, and removes the directory with a bounded retry (Windows: taskkill returns before the child exits) -> UninstallPluginResult (added 2026-09-06, data + full removal 2026-09-06 Wave H) |
updateCheck | GET /api/plugins/:id/update | owner only. Reads the manifest on disk WITHOUT importing anything -> PluginUpdate {from, to, newer, loadable, problems[], delta: GrantDelta, requiresConsent, manifest} (added 2026-09-06) |
update | POST /api/plugins/:id/update | owner only. UpdatePluginRequest {toVersion, granted[]} -> InstalledPluginView. 409 when the disk no longer holds toVersion (a consent given for 1.1.0 cannot be spent on 2.0.0); 400 naming the capabilities when granted does not cover the new manifest. Archives the outgoing version’s directory so rollback is real (added 2026-09-06) |
versions | GET /api/plugins/:id/versions | owner only -> PluginVersionRecord[] — the append-only history of this installation: which version, granted what, when, and whether its code is still archived (added 2026-09-06) |
rollback | POST /api/plugins/:id/rollback | owner only. RollbackPluginRequest {toVersion, granted[]} -> InstalledPluginView. Restores an archived version’s directory and re-consents from scratch; 409 when nothing is archived for that version (added 2026-09-06) |
stage | POST /api/plugins/stage | owner only. StagePluginRequest {projectId, bundle} -> StagedPlugin {candidate, replaced, files, bytes, note}. Project ownership from the session; bundle resolved under <project>/generated-modules and the DESTINATION taken from the re-validated manifest’s id, so no client string reaches the plugins folder. Re-runs validatePluginManifest (a bundle valid when written may not be valid now) and refuses (400) with the problems named, copying nothing. 409 when that plugin id is already installed — that is the update flow, which re-consents (added 2026-09-07) |
sign | POST /api/plugins/sign | owner only. SignPluginRequest {dir} -> PackageProvenance. Signs the directory’s manifest and file digests with the CALLING being’s own key (an AI citizen signs by the identical path a human does) and writes novaterra.sig.json; re-reads and re-verifies before answering. Never emits key material: a did:key is a public key (added 2026-09-07) |
signers | GET /api/plugins/signers | owner only -> SignerLedger {publishers[], scopes[], risk}. Which key this node accepted for which plugin id, and which key holds which @scope. ONE NODE’S MEMORY: never published, never fetched from a peer (added 2026-09-07) |
forgetSigner | POST /api/plugins/signers/forget | owner only. ForgetSignerRequest {pluginId|scope} (exactly one) -> SignerLedger. Drops one remembered binding so a publisher who lost their key is not locked out of their own name for ever. Uninstalls nothing (added 2026-09-07) |
modules
| Operation | Route | Notes |
|---|---|---|
list | GET /api/modules | -> ModulePlace[] for the caller’s own installations. granted on each is the INTERSECTION of what the extension uses and what the owner granted — what it can do, not what it asked for (added 2026-09-06) |
document | GET /api/modules/:id/:extensionId/document | ?widget=<widgetId> for one of the module’s widget documents, otherwise the Place’s own -> ModuleDocument {html, bytes}. Takes a declared NAME, never a path: the file comes from the manifest, so this route can only ever open documents the owner’s install screen listed. Read from inside the plugin’s own directory (realpath-checked), capped at MODULE_BRIDGE_LIMITS.documentBytes. Fetched by the host PAGE and handed to the frame as srcdoc; the frame itself can reach nothing (added 2026-09-06) |
call | POST /api/modules/:id/:extensionId/call | ModuleCallRequest {capability, method, args} -> ModuleCallResponse {value}. Refuses (403) any capability outside uses ∩ grant. Scoped to the session’s being throughout — the body names no being, no plugin and no installation (added 2026-09-06) |
Other
| Operation | Route | Notes |
|---|---|---|
health | GET /api/health | no auth required -> HealthReport {ok, version, time, uptimeSec, llm, db, docker, sandbox, key, budget, connections, memory, embeddings, encryption, storage, lastStop}. storage carries the database/WAL sizes, free disk and writable — the field that goes false when the disk fills, because db.ok is a SELECT and a SELECT succeeds on a full disk; lastStop says whether the PREVIOUS process on this host ended clean/unclean/concurrent. encryption says whether this node could still decrypt what it already encrypted, checked once at boot — a being_keys failure there refuses to start rather than serving. Both are what a watchdog with no session reads (added 2026-09-05, catalogued 2026-09-06, storage+lastStop 2026-09-07, encryption 2026-09-09) |
ws | GET /api/ws | WebSocket upgrade; requires session cookie |