CruxyLearn
PlatformGitHub
LearnDocsAPI ReferenceCookbookResources
API Reference

Cruxy API: every endpoint, typed and interactive.

GET /api/health: Liveness probe

health. Returns 200 as soon as the HTTP listener is up, and cannot return anything else. `commit` is the git commit the running binary was built from (`unknown` for a build that recorded none). Liveness only — it checks no database, schema, or upstream, so it is NOT suitable as a status-page component. Use GET /api/ready for deep readiness.

GET /api/ready: Readiness probe

health. Deep readiness a deploy gates on. Returns 200 only when the database is reachable AND the schema is at or beyond this binary's embedded version; otherwise 503. Distinct from the liveness GET /api/health.

GET /api/status: Public platform status

health. Public, unauthenticated status document for every platform component — the API, the database, each chat tier (kavi, vaani, mira), and each auxiliary serving tier (cruxy-embed, cruxy-rerank, cruxy-guard) — built from active upstream probes rather than from live traffic. Every component carries checked_at and age_seconds; a component not measured recently enough reports "unknown" with a reason, and never a stale verdict. latency_target_ms is present only for the chat tiers (the same value GET /api/v1/models publishes) and is not a threshold. A model component is "degraded" when its median probe latency exceeds latency_degrade_above_ms, when its newest probe succeeded but took longer than latency_slow_above_ms, when its newest probe failed after a success, or, for a chat tier, when the circuit breaker is withholding traffic; it is "down" when its latest two probes both failed. The database is healthy or down. Returns 503 only when no model component has a fresh measurement (i.e. the document itself cannot be trusted); a component that is measurably down is a 200 carrying "down". Components is an array of self-describing rows and may grow — iterate it and read `kind` rather than indexing it. Never cached.

POST /api/v1/auth/oauth/start: Start native OAuth login

oauth-login. Begins a native in-app OAuth login ("Continue with Google/Microsoft/GitHub"). No auth, rate-limited. The CLIENT generates an RFC 7636 PKCE pair and sends the S256 code_challenge; the code_verifier must never be sent here. Open the returned authorization_url in the system browser (never an embedded webview), then POST the code that arrives on your redirect_uri to /api/v1/auth/oauth/token together with the verifier and this state. redirect_uri must be on the server's allowlist FOR THAT PROVIDER — the allowlist is per-provider, so a callback registered for one provider is rejected for another, and for Google it also selects which platform client (iOS or Android) the flow uses. The state is single-use and short-lived.

POST /api/v1/auth/oauth/token: Complete native OAuth login

oauth-login. Exchanges a provider authorization code for a Cruxy API key. Validates the single-use state and the RFC 7636 S256 PKCE verifier, exchanges the code with the provider server-side, resolves the existing Cruxy account from the provider's VERIFIED email (it never creates one: an email with no account is refused with account_required, and the person signs up at https://cruxy.ai/signup first), and mints a cxy_live_ key. The response is byte-identical to POST /api/v1/device/token, so one token-handling path serves both login methods - including `org`, the organization the credential is bound to and the tenant every subsequent request on this key acts in. A user in more than one organization cannot be signed in this way at all (`org_selection_required`): there is no step here where a human could choose. The key is returned exactly once. No cookie is set and no provider token is ever returned.

POST /api/v1/chat: Chat completions

chat. OpenAI-compatible chat completions. model is a public tier - kavi, vaani, or mira - or auto to let the server-side router pick the tier from the request. The canonical path for chat completions is this one, POST /api/v1/chat (native wire: POST /api/v2/chat); an unversioned POST /api/chat is the cruxy-app web BFF's own proxy route on its own origin, not a gateway endpoint - see the API description. stream is optional and false by default. With stream:true the response is OpenAI-style Server-Sent Events: a sequence of `chat.completion.chunk` events, each one object on a `data:` line. FRAME ORDER - a successful stream emits exactly these frames, in this order, and a client must tolerate all of them: (1) a ROLE PRIMER, always first and always before any model output, whose choices[0].delta is {"role":"assistant","content":""} - content is the empty string, not absent - and which carries the turn's cruxy.* block (routed_tier, request_id, reasoning_effort, plus memory_recall / rules_applied when they apply); (2) zero or more CONTENT or TOOL-CALL DELTAS, choices[0].delta carrying either a content fragment or tool_calls fragments (never both), each with finish_reason null; (3) exactly one FINISH_REASON frame, whose choices[0].delta is an EMPTY object {} and which sets finish_reason but carries NO usage; (4) exactly one terminal USAGE frame, which has `choices: []` - an EMPTY ARRAY, no choices[0] to index - and carries the final usage object (and cruxy.memory_capture when the turn stored memory); then (5) the literal line `data: [DONE]`, which is NOT JSON and must not be parsed as such. The usage frame is intentional and contractually stable: it is OpenAI's include_usage convention, which is exactly why frames 3 and 4 are separate and are never combined - a strict OpenAI-compatible client reads usage only from the choices-empty chunk. Note usage.cost is an OBJECT ({amount, currency, input_unit, output_unit, cache_read_unit, tier}), not a number - read usage.cost.amount for a scalar. cost is present on every usage frame, while the additive INTEGER Cruxy usage fields (billable_input_tokens, cache_read_tokens, cache_write_tokens) are omitted when zero and prompt_tokens_details appears only with prompt-cache activity, so their absence means zero rather than an error. Changing SSE framing or frame order is a breaking change requiring a new dated Cruxy-Version, so a client may rely on the order above; new additive cruxy.* fields, however, ship WITHOUT a version bump, so ignore unknown JSON fields. A failure AFTER the stream opens carries no error frame - the 200 is already committed, so the gateway simply stops, ending the response WITHOUT frames 3-5 and WITHOUT the `data: [DONE]` terminator. `data: [DONE]` is therefore the ONLY success signal: treat a stream that ends before it (body closed, or a read returning EOF) as a failed, incomplete completion, and discard or retry the partial output rather than presenting it as a finished answer - the tokens delivered were real and are billed as a partial usage row, but the completion did not finish. For an explicit terminal failure frame instead of truncation, use the native v2 wire (POST /api/v2/chat), whose stream always ends with a named finish or error frame and has no [DONE] sentinel. With stream:false or stream omitted the response is Cruxy-flattened: the assistant reply is the top-level message with a sibling finish_reason, not an OpenAI choices[] array. finish_reason is "stop", "tool_calls" (answer message.tool_calls and continue the turn), "length", or "content_filter". TIMEOUTS AND RETRIES. With stream:false the gateway makes ONE attempt at the model, bounded by the tier's deadline: kavi 90s, vaani 90s, mira 45s. An attempt that runs past it is cancelled and answered 504 upstream_timeout, and nothing is billed. The gateway does NOT retry a timeout, since a turn that needed longer needs longer again: send stream:true (a stream may run for up to five minutes), or ask for less with a lower max_tokens or reasoning_effort. Other upstream failures (an overloaded or failing model, a dropped connection) are retried inside the gateway a bounded number of times, with backoff, before it answers 503 upstream_overloaded or 503 model_unavailable; on stream:true that happens only while the stream is being opened, never after a byte has reached you. The gateway never moves a call to another tier once it is sent: model auto picks a healthy tier before the call, and an explicit tier that kept failing is refused at once with 503 model_unavailable while it cools down. REASONING SPENDS max_tokens: with reasoning_effort omitted each tier applies its default - kavi high (thinking on), vaani low, mira none; auto chooses per request - echoed as cruxy.reasoning_effort, and a reasoning tier generates its thinking inside the same max_tokens budget as the answer without returning it as content. A small max_tokens on kavi can therefore be spent entirely on thinking, ending with finish_reason "length" and empty content; send reasoning_effort:"none" for a short direct answer, or leave max_tokens unset (the tier's cap) to keep reasoning. Cruxy-native fields are additive and namespaced under cruxy.*: the block carries routed_tier, request_id, and reasoning_effort, while token counts and cost live in the usage object (cost at usage.cost). WHICH CREDENTIALS USE MEMORY: memory recall and automatic capture are ON by default for a credential a person gets by signing in (the Cruxy web app, desktop, the CLI's device login, mobile) and OFF by default for a developer API key or a headless key, which opts in per request with memory:{"recall":true} and/or memory:{"capture":true} (see PER-REQUEST MEMORY CONTROLS below). A key minted by POST /api/v1/device/token or POST /api/v1/auth/oauth/token is a signed-in credential whichever client ran the flow, a third-party tool included, so it has memory and rules on; a server must use a developer API key instead (see BearerAuth). GET /api/v1/limits reports the kind as bucket: subscription is signed in, apikey and headless are not. An API key often serves people other than its owner, and recall would put the owner's stored memory into their turns while capture would store what they say, so neither happens unless the request asks. A key whose allowed_scopes omit the memory family can never use memory through chat - asking with true is 403 key_scope_forbidden (required_scope memory), refused before anything is billed - which makes it the key to issue for a service that must never see anyone's memory. When recall runs, relevant stored memories are recalled server-side and added to the turn as background context, drawn from the same resolved view GET /api/v1/memories lists: the caller's personal memory, their organization's, and the vaults they can read. The optional vault_id names the vault a chat is happening inside - it does not gate vault memory generally (a readable `default` vault's memory is recalled either way), it selects the ISOLATED case: inside a `vault_only` vault recall draws on ONLY that vault's memory, excluding personal and organization memory, and outside it that vault contributes nothing. A vault_id the caller cannot read is treated as none and never reported, so the request cannot be used to test whether a vault id exists. What was recalled is reported additively as cruxy.memory_recall (scope - personal, org, or vault - and key per memory; never the stored value), and a vault-scope memory names the vault it came from (vault_id, plus vault_name when it resolves). A turn may also REFERENCE vaults it is not inside with mentioned_vault_ids - the ids behind an "@VersionGate", resolved by your own picker, never by the gateway reading names out of the message. At most 3 are used; ids you cannot read, repeats, and the active vault_id are dropped silently, so the response is identical to one that never sent them and the field cannot be used to probe whether a vault id exists. An accepted mention adds that vault's memory to this turn's recall - sharing the same recall budget rather than enlarging it, and keeping each vault's isolation intact - without making the vault writable. WHICH CREDENTIALS GET STANDING RULES: the same default as memory. Your organization's standing rules are injected by default for a credential a person gets by signing in, and NOT for a developer API key or a headless key, whose rules are its owner's organization's guidance and which a visitor to whatever the key serves could ask the model to repeat. Such a key opts in per request with rules:true, or once, on the key, by being issued allowed_scopes that NAME the rules family (an unscoped key has every family but has not opted in). Any credential may send rules:false to decline them for a turn, which needs no scope since the user's own message already overrides them. A key whose allowed_scopes omit rules never gets them, and rules:true on it is 403 key_scope_forbidden (required_scope rules), refused before anything is billed. The switch covers the whole resolved ruleset, the organization's and, with vault_id, the vault's, so a turn gets exactly what GET /api/v1/rules/resolved returns or nothing. When rules apply and the caller's organization has configured them, they are resolved server-side and injected as guidance for the turn - as DEFAULTS the user's own message overrides, never as constraints that outrank the user - and the response signals this additively as cruxy.rules_applied (how many rules applied and from which rulesets; never the rule text, which is readable at GET /api/v1/rules/resolved). vault_id selects which rules apply too: inside a vault the turn gets that vault's own ruleset merged with the organization's per the vault's inherit_org_rules switch - the same answer GET /api/v1/rules/resolved gives for that vault - and rules_applied names each contributing scope (org, vault). A vault_id the caller cannot read is treated as none here as well, so the turn simply runs with organization-scope rules. Injected rule text is real upstream input and is counted in usage.input_tokens like any other. When capture runs, after a turn is answered the gateway also decides whether the exchange contained a durable fact worth remembering - a stated preference, a stable setting, a constraint about your work - and stores it automatically for future turns. EVERY such capture is reported in the same response as cruxy.memory_capture (how many memories were stored, and the scope and key of each; never the stored value), so nothing enters memory without the response naming it: no field means nothing was stored. Captured memory is written to your own personal memory, or - when the chat names a vault_id you may write - to that vault, so a fact learned in a shared space is remembered there; a vault you may only read demotes the capture to your own memory. It is NEVER written to organization-wide memory, whatever your role. Content carrying an email address, phone number, or card number is dropped before any write, on the same terms as the manual write endpoint. Capture cannot fail or delay a turn: if extraction is unavailable it simply stores nothing. Everything stored is listed by GET /api/v1/memories and removable with DELETE /api/v1/memories/{id}. PER-REQUEST MEMORY CONTROLS: an API key opts in with memory:{"recall":true} (inject the stored memories most relevant to this turn) and/or memory:{"capture":true} (allow a durable fact from this turn to be stored); any credential may send memory:{"recall":false} to inject no stored memory into this turn (no memory is read, mentioned_vault_ids are not consulted, no cruxy.memory_recall is returned, and input tokens fall by the size of the block that would have been injected), and memory:{"capture":false} to store nothing from it (no extraction, no write, no cruxy.memory_capture). The two sides are independent: {"capture":false} alone keeps the turn personalised by what is already stored while storing nothing new. A missing memory object, a missing key, or null means the credential's default (on for a signed-in client, off for a developer or headless API key), and a literal true or false states your choice. false needs no key scope, since it only makes the gateway do less for your own request; true needs a key whose allowed_scopes include memory (an unscoped key has every family). Standing rules are not memory, and this field never affects them: they have their own switch, rules (see WHICH CREDENTIALS GET STANDING RULES above). Pin Cruxy-Version 2026-09-26 on every request that relies on memory:false: unknown fields are ignored rather than refused, so a gateway that predates this field would answer 200 and capture anyway, and a pinned request is instead refused by such a gateway with 400 unknown_version before recall, the upstream call, billing or capture run. This is the one exception to the rule above that a dated Cruxy-Version is only for breaking changes: a date may gate an additive request field whose silent absence on an older gateway would make the response misrepresent what it used, whether a privacy promise (as here) or content the user supplied. It is not precedent for ordinary additive fields, which still ship without a version bump. ATTACHED FILES: a user message may carry file_ids, the ids of files you uploaded with POST /api/v1/files under the same organization and user as this credential, and the gateway reads each one into the turn. The request must pin Cruxy-Version 2026-10-05 or later: a request naming file_ids without that pin is 400 invalid_request (carrying required_version), because a gateway that predates the field would ignore it and answer without the files, and the pin is what makes such a gateway refuse instead. UTF-8 text files are read (plain text, markdown, CSV, JSON, source code), and so are PDFs and Word documents (.docx), as the text read out of them once, when they were uploaded (POST /api/v1/files reports it as text_status): a document is framed, measured against the caps and billed by its text exactly as a text file is, and is never parsed on a turn. A file of another type, or whose bytes are not UTF-8 text, is 400 file_not_text, and so is a document whose text could not be read, with a reason field saying why: no_text (no text found, such as a scanned PDF), encrypted (password-protected), corrupt, unreadable (reading it went past the time or memory allowed for one document), legacy_doc (a Word 97-2003 .doc) or not_extracted (uploaded before documents were read; upload it again). A file over the per-file token cap, or one that takes the request past the per-turn cap, is 400 file_too_large, and so is a PDF of more than 500 pages (bound pages) or over 64 MiB (bound bytes), which is not read; for a document the message advises attaching a shorter excerpt. Both name the file (file_id, filename, and for file_too_large bound, limit and tokens). An id that is not yours, whether another user's, one filed under another of your organizations, missing or malformed, is 404 file_not_found, the same answer in every case. A key whose allowed_scopes omit files is 403 key_scope_forbidden (required_scope files). All of these are answered before anything is sent upstream or billed. Each file's text goes in as a user-role block immediately before the message that named it, framed as material for that message and never as instructions to follow; it never enters the system prompt, recall does not search with it, and automatic capture does not read it. A file named on several messages is sent once, before its first mention. Its tokens are input like any other and are billed on EVERY request that sends the file, wherever its block sits: a file id you keep in a conversation's history is billed on each turn, not only the first. They count toward the tier's context window: a file on an earlier message that does not fit beside the conversation, with room left for the reply's max_tokens and a margin of a tenth of the window, is left out, oldest first, and listed in files_omitted with reason context_window, and a turn that does not fit even with every earlier file left out is 400 context_overflow. POST /v1/chat/completions refuses file_ids, and POST /api/v1/tokens/count does not count them. The additive fields never remove or alter the OpenAI-shaped message, so a vanilla OpenAI client keeps parsing.

GET /api/v1/conversations: List conversations

conversations. Lists the CALLER'S OWN conversations, most recently active first. **Private to their author**: this never returns a conversation authored by anyone else, whatever the caller's organization role and whatever vaults they share. Keyset-paginated like the request-log listing: pass `?limit` (default 50, max 100) and the opaque `?cursor` from a previous response's `next_cursor`, which is null on the last page. Filter with `?vault_id` (only conversations held in that vault; a vault the caller cannot read simply matches nothing), `?archived` (false, the default, lists live conversations; true lists the archived shelf), and `?source` (chat, the default, cli, or agent: only conversations that surface wrote, so a chat client that never names a source never sees a Cruxy Code session). Each entry carries `permissions`, resolved from the same predicate the write routes enforce. Messages are not included - fetch a conversation by id for those.

POST /api/v1/conversations: Create a conversation

conversations. Creates a conversation owned by the calling user. **Conversations are private to their author** - no other member of the organization, including an owner or admin, can read, list, or search it. Optional `title` and `model` are presentation only. Optional `vault_id` holds the conversation inside a vault: it then requires `can_write` on that vault, and it is visible thereafter only while the author can still read that vault. A vault the caller participates in but cannot write is 403 vault_forbidden; one in another organization, one that does not exist, and one the caller does not participate in are all 404 vault_not_found. Omitting `vault_id` creates a loose conversation, which is the normal case. Optional `source` says which surface is writing it: `chat` (the default) or `cli` for a Cruxy Code session; `agent` is 400 invalid_request, because only the gateway writes agent transcripts. Optional `meta` is a JSON object of at most 4096 bytes once compacted, stored and returned verbatim; anything else is 400 invalid_request. The 201 body is read back from the committed transaction, so it agrees field-for-field with the next GET - including `retention_until`, the deadline after which the conversation and its messages are deleted (365 days from last activity by default).

GET /api/v1/conversations/search: Search your own messages

conversations. Full-text search over the CALLER'S OWN messages, most relevant first. **It can never see more than the listing can**: this route and GET /api/v1/conversations embed the identical visibility predicate, so a colleague's message - including an organization admin's view of a member's, and including in a vault they share - is not merely omitted from the results but never matched. `q` is parsed with `websearch_to_tsquery`, so quoted phrases, `or`, and a leading `-` to exclude all work, and no input is a syntax error. Each hit carries a `snippet`: an excerpt with matched terms delimited by the literal markers `[[hl]]` and `[[/hl]]`. **They are deliberately not HTML** - a snippet mixes raw user-authored text with the markers, so a client must escape the whole snippet first and substitute the markers afterwards; a client that does not want highlighting can simply strip them. Archived conversations are searched too, and so is every branch of a branched conversation: a message on a branch that is not active is still the caller's own text, so it is returned with `on_active_path` false rather than hidden. `on_active_path` is true for every message of a conversation that has never forked, and otherwise for the active leaf and its ancestors, the messages GET /api/v1/conversations/{id} reads. To open an off-path hit, POST its `message_id` to /api/v1/conversations/{id}/switch. `?source` (chat, the default, cli, or agent) searches only conversations that surface wrote, matching the listing's filter. `?limit` defaults to 20, max 50.

DELETE /api/v1/conversations/{id}: Delete a conversation

conversations. Deletes a conversation and every message in it. Irreversible and immediate - there is no trash, and archiving (PUT /api/v1/conversations/{id}/archive) is the reversible option. The deletion is recorded so it can be explained later (the same happens when a conversation reaches its retention deadline): the record is metadata only - the conversation id, its author, organization and vault, how many messages it held, when it was created, and the request's X-Request-Id - and never its title or any message text. Requires `permissions.can_write`; a conversation held in a vault the caller may read but not write is 403 conversation_forbidden, and one the caller cannot see at all is 404 conversation_not_found. **Attached chat attachments go with it**: every file a message of this conversation attached with purpose chat_attachment is deleted too, unless a message in another conversation still attaches it, in which case it stays until the last such message is gone. A file uploaded for any other purpose is never deleted by this route; it is only removed from this conversation's messages, and stays in the Files API until its owner deletes it or its expires_at passes. Each deleted file is recorded the same way the conversation is: metadata only, never its filename. A file no message attached is never touched.

GET /api/v1/conversations/{id}: Get a conversation with its messages

conversations. Returns one conversation and a page of its messages. **Private to its author**: a conversation authored by another member - including when the caller is an organization admin and a member of the same vault - is 404 conversation_not_found, indistinguishable from an id in another tenant and from one that never existed. A 403 would confirm the id is real. Messages are paginated independently of the conversation listing: `?message_limit` (default 100, max 200) and the opaque `?message_cursor` from `messages.next_cursor`, which is null on the last page. `?message_order` selects the direction: `asc` (the default, oldest first) or `desc`, which returns the NEWEST messages first - the tail of a long thread in one request, rather than paging forward through the whole of it. Ordering is ranking only and never changes which messages are visible. The page echoes the direction it was read in as `messages.order`. **A `message_cursor` may only be used with the `message_order` it was issued for**; the other direction is 400 invalid_request rather than a silently different page, because one anchor names two disjoint pages depending on direction. Each message carries `status` - `complete` (the model finished its answer), `incomplete` (the answer is not whole, however the stream ended: a dropped connection, a timeout, or a length cap, i.e. `finish_reason: "length"`, which ends the stream cleanly), or `cancelled` (a human stopped it) - so a partial reply is never rendered or replayed as a finished one. A conversation can branch, and the page is ONE PATH through it, read from `messages.leaf_id` back to the first message: the active branch on a first page. A `message_cursor` carries its page's leaf, so later pages stay on that branch even if the conversation is switched to another between requests. **`?message_branch` reads another branch without switching to it**: given a message id, the page is the branch through that message, from its deepest descendant by newest child, exactly the branch `POST /api/v1/conversations/{id}/switch` with that message would make active, while the conversation stays on its active branch for every device. It is a read, so it needs no write permission. Walking each message's `sibling_ids` this way, starting from the branch through `active_leaf_message_id` (the active leaf can have a child stored without moving it, which the plain read ends above), reads every branch of a conversation, which is how a client exports the whole tree. A `message_branch` that is not a message of this conversation is 404 message_not_found. It starts a read: later pages follow `messages.next_cursor`, which carries the branch, and sending it with `message_cursor` is 400 invalid_request.

PATCH /api/v1/conversations/{id}: Rename or retag a conversation

conversations. Changes a conversation's `title` and/or `model`. A title set here is a USER DECISION and outranks the automatic one: auto-titling only ever fills a `title` that is null, so a rename is permanent and no later message undoes it. Both are optional but at least one must be present; an omitted field is left unchanged, an explicit `null` clears it, and an empty patch is 400 invalid_request rather than a no-op 200 that would tell a client its request applied when it named nothing. `vault_id` is deliberately NOT patchable: moving a conversation between vaults changes who can see it, and that is a separate operation with its own authorization rather than a side effect of a rename. Requires `permissions.can_write` — false only when the conversation is held in a vault the caller may read but not write, which is 403 conversation_forbidden. A conversation the caller cannot see at all is 404 conversation_not_found.

DELETE /api/v1/conversations/{id}/archive: Restore a conversation

conversations. Clears a conversation's archive, returning it to the default listing. Idempotent: restoring a live conversation is 204. Requires `permissions.can_write`.

PUT /api/v1/conversations/{id}/archive: Archive a conversation

conversations. Archives the conversation: it leaves the default listing and appears only with `?archived=true`. The conversation and every message are untouched — archiving is not deletion, and archived conversations are still searched, because archiving means "stop showing me this", not "forget it". Idempotent: archiving an archived conversation is 204 and does not move the original archive instant. Archiving does NOT extend the retention deadline; shelving something is not activity. Requires `permissions.can_write`.

POST /api/v1/conversations/{id}/messages: Append a message to a conversation

conversations. Appends one message to a conversation. **Where it goes in the tree**: with no `parent_id` the message answers the current leaf (`active_leaf_message_id`, or the newest message while that is null), as every append always has; with one, it answers that message, which must belong to this conversation; with `root: true` it answers nothing, and is a new root beside the first message, which is how a client edits the first message. `root` and `parent_id` together are 400. Send `expected_leaf`, the leaf you believed was current, with any message that may arrive after the conversation has moved on (an edit, a regeneration, a streamed reply). If it still is current, or you omit it, the append moves the leaf to the new message; if not, the message is stored under its parent anyway, the leaf is left on the branch someone switched to, and `leaf_moved` is false. A stale `expected_leaf` never refuses an append. A `parent_id` or `expected_leaf` that is not a message of this conversation is 400. A conversation that has never branched keeps a null `active_leaf_message_id` for as long as every append answers its newest message. `role` is one of `system`, `user`, `assistant`, `tool`. `status` is `complete` (the default: the model finished its answer), `incomplete`, the answer is not whole however the stream ended (a dropped connection, a timeout, or a length cap: a reply that ended with `finish_reason: "length"` is `incomplete` even though its stream closed cleanly), or `cancelled`, a human stopping it deliberately; storing it is what keeps a partial reply from being rendered or replayed as a finished one. **An agentic turn must carry its tool fields**: `tool_calls` on the assistant message that requested them (same shape /chat returns), and `tool_call_id` on each tool result naming the call it answers. `content` may be empty on an assistant message that carries `tool_calls`, the calls are the message, and is required otherwise. `tool_calls` is valid only on role `assistant`, `tool_call_id` only on role `tool` and required there; anything else is 400. A message stored without them reads back blank and replays as a transcript /chat will not accept. The `sequence` is assigned SERVER-SIDE under a row lock, so concurrent appends cannot collide or reorder the transcript, and it is returned on the created message. **The first user message NAMES the conversation.** If `title` is still null it is derived from that message's content and set in the SAME transaction as the append, so the `conversation` echoed in this response already carries it, a title that landed later would land after a client had already rendered "Untitled chat". Derivation is mechanical, never model-generated: the first line, whitespace collapsed and control characters dropped, truncated to 80 characters on a word boundary with an ellipsis. It applies ONLY when `title` is null, a title you set at create or by PATCH is a decision and is never overwritten, and ONLY from a `user` message, since an assistant turn is the model's words rather than yours. Content carrying no letter, digit or symbol (`"..."`, `"???"`) leaves `title` null and the next user message that does carry words names the thread instead: a meaningless title looks like a name someone chose, where null correctly says the conversation has not been named. Appending advances the conversation's `updated_at` and re-stamps its retention deadline, so a conversation in use never ages out. **Send an `Idempotency-Key` header**: a retry with the same key and body replays the stored 201 rather than writing the turn twice, and the same key with a different body is 409 idempotency_conflict. **Attached files**: a `user` message may carry `file_ids`, files you uploaded with POST /api/v1/files, and the conversation keeps them: every read returns each message's `file_ids` in the order sent (an empty array when it attached none), which is what a client sends again as /chat's `file_ids` on later turns. The request must pin `Cruxy-Version: 2026-10-06` or later; without it, `file_ids` is 400 invalid_request with `required_version`, because a gateway that predates attachments would store the message without them and answer 201. At most 20 distinct files, a repeat attached once. Each must be a file of yours under this credential's organization: another user's, one filed under another of your organizations, one whose `expires_at` has passed, a missing id and a malformed one are all 404 file_not_found naming the id, and the append writes nothing. A key whose `allowed_scopes` omit `files` is 403 key_scope_forbidden. Deleting the conversation, or its retention deadline passing, deletes every attached file that no message in another conversation still attaches; a file attached elsewhere lives until its last attaching message goes. Deleting a file yourself removes it from the messages that listed it. Requires `permissions.can_write`.

POST /api/v1/conversations/{id}/switch: Switch a conversation to another branch

conversations. Moves the conversation onto the branch through `message_id`, the message a version switcher picked (for example the sibling shown as "1 / 2"). The server resolves the **deepest descendant** of that message, following the newest child at each level, and makes it the conversation's active leaf, so switching back to a branch restores it with every message written on it since. The response carries that leaf as `leaf_id`; the next `GET /api/v1/conversations/{id}` reads the branch ending there. **The switch is shared state**: every device reads the branch it names, so it requires `permissions.can_write`, and a conversation held in a vault the caller may read but not write is 403 conversation_forbidden. A `message_id` that is not a message of this conversation is 404 message_not_found, whether it belongs to another conversation, another tenant, or nothing. Switching is **not activity**: `updated_at` and `retention_until` do not move, so a switch never reorders the conversation listing. A conversation that has never branched stays unbranched: every message of it resolves to its newest message, and `active_leaf_message_id` stays null. Switching to the branch already active changes nothing and still succeeds. The switch takes the same per-conversation lock an append takes and reads the tree after it, so a switch racing an append to the same branch always sees the appended message if the append committed first.

POST /api/v1/device/code: Start device login

device-login. Begins an RFC 8628 device-authorization flow (no auth, rate-limited). Returns the client's device_code, the human-typed user_code, and where to approve. Poll POST /api/v1/device/token with the device_code until approved.

POST /api/v1/device/token: Poll device login

device-login. The CLI polls with its device_code. Returns 200 with a freshly minted API key once a human has approved the flow, or an OAuth-style error (400) while pending/denied/expired/invalid. The key is returned exactly once. It is a SIGNED-IN CREDENTIAL, whichever client ran the flow: in chat it gets the person's memory recall, automatic memory capture and their organization's standing rules by default, it expires at expires_at (90 days after issue by default) and use does not renew it, and its holder cannot rotate or revoke it. Keep it in the person's own tool. Do not deploy it in a server or in anything that serves other people: use a developer API key from the Cruxy Console there (see BearerAuth), or send memory:{"recall":false,"capture":false} and rules:false on every chat request. The 200 carries `org`: the organization the credential is bound to, with the signed-in user's role in it. Show it — a login binds to exactly one organization, and it is not always the one the previous login used. A 400 may additionally carry `reason` and `error_description`, both **additive** and both absent on most refusals; `error` and `code` are unchanged either way. The one `reason` today is `org_membership_revoked`: the authorization was approved for an organization the account no longer belongs to. Starting a new login will succeed, but it may bind to a **different** organization, so tell the user rather than retrying silently.

POST /api/v1/embeddings: Create embeddings

embeddings. OpenAI-compatible embeddings. model must be "cruxy-embed". input accepts a single string or an array of strings — at most 512 strings per request, with the whole body ≤ 64 KiB (either exceeded is a 400). Each embedding is a fixed 1024-dimensional dense vector (size your vector store to 1024). The additive cruxy.* block carries the request id and cost; the OpenAI-shaped fields are unchanged.

GET /api/v1/evals/runs/{id}: Get an eval run

evals. Returns a run with its status and a summary of results so far: total cases, how many are completed, and the pass/fail split. Poll this after starting a run. A run id in another workspace or organization, or one that names nothing, returns `404 eval_run_not_found`.

GET /api/v1/evals/runs/{id}/results: List a run's results

evals. Lists the per-case results of a run: the model's output, the pass/fail verdict, and the latency of each completion, oldest first. A run id in another workspace or organization, or one that names nothing, returns `404 eval_run_not_found`.

GET /api/v1/evals/suites: List eval suites

evals. Lists the eval suites in the caller's workspace, each with its case count. Ordered oldest first.

POST /api/v1/evals/suites: Create an eval suite

evals. Creates an eval suite in the caller's workspace — a named container for prompt/expected cases. The workspace is the one the credential names; there is no workspace or org id in the path or body. `name` is required (1..128 bytes). `slug` is the stable, URL-safe handle and is optional: when omitted it is derived from the name; a slug already used in the workspace is `409 eval_suite_slug_exists`. `prompt_id` optionally links a stored prompt in the SAME workspace (advisory metadata; a prompt_id outside the workspace is `400 invalid_request`). Returns the created suite.

DELETE /api/v1/evals/suites/{id}: Delete an eval suite

evals. Deletes a suite and all of its cases, runs, and results (they cascade). A suite id in another workspace or organization, or one that names nothing, returns `404 eval_suite_not_found`.

GET /api/v1/evals/suites/{id}: Get an eval suite

evals. Returns one suite of the caller's workspace. A suite id in another workspace or organization, or one that names nothing, returns `404 eval_suite_not_found`.

GET /api/v1/evals/suites/{id}/cases: List a suite's cases

evals. Lists every case of a suite, oldest first. A suite id in another workspace or organization, or one that names nothing, returns `404 eval_suite_not_found`.

POST /api/v1/evals/suites/{id}/cases: Add a case to a suite

evals. Appends a case to a suite. `input` is the prompt sent upstream verbatim (1..102400 bytes); `expected` is the reference answer (1..102400 bytes); `match_mode` is `exact` (output equals expected, trimmed) or `contains` (output contains expected). A suite id in another workspace or organization, or one that names nothing, returns `404 eval_suite_not_found`.

DELETE /api/v1/evals/suites/{id}/cases/{case_id}: Delete a case

evals. Deletes one case of a suite. A case that is not in the suite, or a suite id in another workspace or organization, returns `404 eval_case_not_found`.

POST /api/v1/evals/suites/{id}/runs: Start an eval run

evals. Enqueues an asynchronous run of a suite against `model` (one of `kavi`, `vaani`, `mira`) and returns immediately with the run in status `pending` — it does NOT block on execution. A background worker then runs each case through the standard chat path, so the run **costs money**: every case is a billed completion charged to the credential that created the run, exactly as an interactive chat call would be. Poll `GET /api/v1/evals/runs/{id}` for status and results. A suite id in another workspace or organization, or one that names nothing, returns `404 eval_suite_not_found`.

POST /api/v1/feedback: Submit feedback

feedback. Inbound flywheel signal joined to a prior call by request_id. PRIVACY: a non-consented or opted-out event is dropped entirely (stored=false, no row); a consented event is stored with its payload redacted. `request_id` must be the `cruxy.request_id` of a call THIS account made; an id belonging to another account, or one that names no call at all, is one indistinguishable `404 request_not_found`. The opt-out is a durable account-level setting managed in the Cruxy app, independent of this request's `consent` flag; either a `consent:false` event or an active account opt-out is dropped. There is no public endpoint to read or change the opt-out.

GET /api/v1/files: List files

files. List the authenticated user's files, newest first. Keyset-paginated: pass ?limit (default 20, max 100) and the opaque ?cursor from a previous response's next_cursor. Only the caller's own files are ever returned. Returns {data:[…], next_cursor} where next_cursor is null on the last page.

POST /api/v1/files: Upload a file

files. Upload a file as multipart/form-data. The file part is named "file"; an optional "purpose" text field labels it (short [A-Za-z0-9._-] token, default "general"). Content is stored opaquely under a generated id - never executed, never interpreted, never addressed by its original filename. Its media type is detected by the server from the content's first bytes, refined by the filename's extension only when the content already reads as UTF-8 text, and returned as content_type; the Content-Type on the multipart part is ignored. A JPEG, PNG or WebP image is recorded as image/jpeg, image/png or image/webp whatever its name, and chat shows it to a tier that reads images when a message names it in file_ids. A single file may be at most FILES_MAX_FILE_BYTES (default 512 MiB); exceeding it is 413. This endpoint is authenticated and per-key rate-limited but NOT billed. LIFETIME: an optional "expires_in" text field gives the file a lifetime in seconds, from 60 to 604800 (7 days), returned as expires_at. The moment it passes the file is gone to every read: it is not listed, its metadata and content are 404, it no longer counts against the quota, and chat cannot read it; the bytes are deleted from storage on the next sweep, within 15 minutes. A file uploaded from a temporary chat should always carry one. Setting it requires pinning Cruxy-Version 2026-10-07 or later, because a gateway that predates the field would keep the file forever and answer 201; without the pin, or with a value out of range, the upload is 400 invalid_request and nothing is stored. Without expires_in a file lives until you delete it, as before. UNATTACHED CHAT ATTACHMENTS: an upload with purpose "chat_attachment" exists to be attached to a conversation message (POST /api/v1/conversations/{id}/messages with file_ids). One that no message has attached 24 hours after upload is deleted, row and bytes, by the same sweep that enforces lifetimes, so a file picked in a chat and never sent does not live forever. An attached one is kept until the last message attaching it is deleted. No other purpose is ever deleted for being unattached. DOCUMENTS: a PDF, or a Word document (.docx, recognised from the zip's own content types and recorded as application/vnd.openxmlformats-officedocument.wordprocessingml.document), has its text read once, as part of the upload, so chat can read it like a text file (POST /api/v1/chat file_ids). The reading runs in a separate, bounded process (at most 15 seconds, 256 MiB, 500 pages and 64 MiB of document) and never fails the upload: the file is stored whatever it holds, and text_status reports what reading it found: extracted (text_tokens is its size, by the same characters/4 estimate chat's caps use), no_text (no text found, such as a scanned PDF), encrypted (it needs a password), corrupt (damaged, or a feature the reader does not support), too_long (over 1 MiB of text, which is not kept; text_tokens says how much there was), too_many_pages, too_big, failed (reading ran out of time or memory), or busy. Reading runs at most two documents at once; a document that arrives while both are busy waits in line for up to 30 seconds, behind at most 20 others, and only one that is still unread then is busy: nothing is wrong with the file, and the same file uploaded again is read. text_status and text_tokens are null for every other file. A legacy Word 97-2003 document is recognised from its bytes and recorded as application/msword, and is not read. A document's stored text counts toward FILES_MAX_USER_BYTES beside its bytes. The file belongs to the ORGANIZATION the credential names as well as to the user, and FILES_MAX_USER_BYTES (default 2 GiB) is metered per (organization, user) - so for a member of more than one organization each tenant has its own pool, and a file uploaded with one organization's credential is not visible to another's.

DELETE /api/v1/files/{id}: Delete a file

files. Delete one of the authenticated user's files and its content. A file belonging to another user, or to another of your own organizations, is 404 — the same answer as a missing or malformed id, since a distinguishable response would confirm the id exists. Idempotent from the client's view: a repeat delete of an already-deleted id is 404.

GET /api/v1/files/{id}: Get file metadata

files. Fetch metadata for one of the authenticated user's files, within the organization the credential names. A file belonging to another user — or to another of your own organizations — returns 404 (never 403, which would confirm the id exists), as does a missing or malformed id.

GET /api/v1/files/{id}/content: Download file content

files. Stream the raw bytes of one of the authenticated user's files. Always returned as application/octet-stream with Content-Disposition: attachment and X-Content-Type-Options: nosniff — content is never rendered inline. A file belonging to another user, or to another of your own organizations, is 404 — the same answer as a missing or malformed id, since a distinguishable response would confirm the id exists.

GET /api/v1/invitations: List invitations addressed to me

organization. Lists the live invitations addressed to the caller, across every organization — the pending offers to join somewhere. Settled and expired invitations are not listed. This route names no organization and takes none: it resolves through the caller's own account, so there is no parameter by which one person could read another's invitations. Each entry carries the inviting organization's name and the inviter's address, so the offer can be attributed before it is accepted.

POST /api/v1/invitations/{invitation_id}/accept: Accept an invitation

organization. Accepts an invitation addressed to the caller and joins that organization with the role the invitation offered. This is the only way a membership is created on a user credential's authority. An invitation that names somebody else, one that never existed, one that has expired, one already settled, and a malformed id are one `404 invitation_not_found` — there is deliberately no answer meaning "that invitation exists but is not yours". Joining an organization while already belonging to another means credential issuance can no longer resolve an organization implicitly; new credentials must name one (`org_selection_required`). Existing credentials are unaffected.

POST /api/v1/invitations/{invitation_id}/decline: Decline an invitation

organization. Declines an invitation addressed to the caller. Nothing is created and the invitation is settled, so it stops appearing in the inbox and stops being acceptable. Declining is exactly as private as accepting: an invitation that names somebody else, one that never existed, one that has expired, one already settled, and a malformed id are one `404 invitation_not_found`. The inviting organization sees the outcome on its own invitation list; it is not told anything else.

GET /api/v1/limits: Get my limits and headroom

limits. Returns the authenticated credential's tier, billing bucket, and current rate/budget limits with live remaining headroom — every value read from the same limiter and budget state the chat path enforces on, so `remaining` here is exactly what the next request is gated against. **Credential-derived and workspace-scoped**: tier (`users.plan`), bucket, the per-key spend cap, and the per-key request-rate all come from the authenticated key; the workspace spend cap is for the workspace the key binds to. None of it is read from the request. **`rate_limits.chat`** carries the per-key and per-org chat request-rate ceilings (requests per 60s window) and their remaining count; `resets_at` is null when no request has opened the current window. **`token_pool`** (subscription bucket only) is the weighted-token budget: the calendar-month cap and the trailing-12h sliding burst cap, each with used/remaining in weighted tokens, plus the Mira monthly request kill-switch. **`credits`** (headless bucket only) is the dollar-credit pool. **`key_spend_cap`** / **`workspace_spend_cap`** appear only when a monthly micro-USD ceiling is set, reported in USD. There is deliberately no tokens-per-minute limit and no per-model daily cap — Cruxy enforces neither, so neither is invented here. No write, no cost. The endpoint is itself rate-limited and emits the standard `X-RateLimit-*` headers for its own bucket.

GET /api/v1/logs: List request logs

organization. List request metadata for the caller's organization, newest first — one row per request the org's API keys made, drawn from the usage ledger. **Owner or admin only** (`logs_forbidden`): this is operator surface spanning every member's keys, not a per-user view. **Metadata only** — model, token counts, latency, lifecycle status (`success`/`error`/`partial`), the originating key and workspace, request id, and timestamp. Prompt and completion content are never stored and never returned. Keyset-paginated like the admin users list: pass `?limit` (default 50, max 100) and the opaque `?cursor` from a previous response's `next_cursor`. Filter with `?key_id`, `?workspace_id`, `?model`, `?status`, and an `?from`/`?to` RFC3339 time window (from inclusive, to exclusive). A `key_id` or `workspace_id` outside the caller's org simply matches nothing. Returns `{object:"list", data:[…], next_cursor}` where `next_cursor` is null on the last page.

GET /api/v1/logs/{request_id}: Get request log

organization. Fetch metadata for a single request by its `request_id` (the `cruxy.request_id` returned on chat/embeddings/rerank responses). **Owner or admin only** (`logs_forbidden`). **Metadata only** — never prompt or completion content. A request id in another organization, or one that names nothing, returns `404 log_not_found` — a 403 would confirm the id is real. A request id may map to more than one ledger row (a reused Idempotency-Key, or an error followed by a retried success); the most recent is returned.

GET /api/v1/me: Current identity

auth. Echoes the authenticated identity resolved from the Bearer key, proving the key pipeline works end to end.

DELETE /api/v1/memories: Forget all my memory

memory. Permanently deletes every `user`-scope memory belonging to the caller and reports how many rows were removed. This is a real delete, not a flag — the rows are gone and nothing resurrects them. It is scoped to the caller: an org-mate's memory, and every other tenant's, is untouched. **Shared memory is not included.** A vault's or an organization's contents belong to the container, not to whoever typed them, so removing one is a `DELETE /api/v1/memories/{id}` gated by `can_write` on that vault or by the organization owner/admin role — or, for a departed member's writes, `POST /api/v1/memories/departed/{user_id}`. `?scope=vault` and `?scope=org` are refused with `invalid_request` for that reason, rather than returning a misleading `deleted: 0`.

GET /api/v1/memories: List memories

memory. Returns the caller's effective memory — one entry per key, with the most specific scope winning (user beats vault beats org), merged across all three scopes. **Each resolved entry carries the `id` of the row that WON** — the row whose `value` is being shown, at the scope `resolved_from` names — and it is the id `DELETE /api/v1/memories/{id}` takes, so a review-and-forget surface can remove exactly what it is displaying without re-listing each scope's raw rows to work out which one supplied the value. Deleting it lets the next-most-specific scope surface on the following read. **The `id` is an address, not a permission**: delete authority is unchanged by its presence and is exactly write authority — your own `user` rows, a `vault` row where your membership grants `can_write` and is not held, an `org` row only if you are an owner or admin. A row you may read but not write returns **404** (not 403 — an id is never confirmed to a caller not entitled to act on it), so a client rendering a forget control should take the vault leg from that vault's own `permissions.can_write` (`resolved_from.vault_id` names it) and the org leg from `permissions.can_write` on `GET /api/v1/rules`, which any member may read and which reports the same organization-role predicate this delete gates on. The org leg covers organization-wide memory when the caller is still a member of the organization; the vault leg covers every vault whose membership grants the caller `can_read` without a hold, and when two readable vaults define the same key the most recently updated wins and `resolved_from.vault_id` reports which one. A vault whose `memory_scope` is `vault_only` is isolated and does not contribute to this general view — its memory surfaces only when resolving inside that vault. Precedence is resolved server-side; clients consume the result and never recompute it. Pass `?scope=user`, `?scope=org`, or `?scope=vault&vault_id=…`, to see the raw stored rows at one scope instead.

PUT /api/v1/memories: Remember a value

memory. Stores a memory under a key, replacing any existing value for that key at the same scope. Keys are case-insensitive, so "Timezone" and "timezone" are the same memory. **Content carrying obvious PII is refused, not redacted**: if the key or value contains an email address, a phone number, or a Luhn-valid card number, the request fails with `pii_rejected` and nothing is stored — memory has no expiry, so anything accepted is kept until it is explicitly deleted. The error names the category that matched and never echoes the matched text; rephrase without it and retry. The check is server-side and unconditional — no request field disables it, and it applies identically at every scope. `user` scope is private to the caller. `vault` scope requires a `vault_id` and a vault membership granting `can_write` — a held member, a non-member, and a vault in another organization all return 404. `org` scope writes memory shared with the whole organization and requires the **owner or admin** role; an ordinary member gets `org_forbidden`. The role is read from the credential's organization membership and can never be asserted in the request.

POST /api/v1/memories/departed/{user_id}: Settle a departed member's shared memory

memory. Decides what happens to the `org`- and `vault`-scope memory written by someone who has left the organization: `keep` retains every row and moves authorship to you, `purge` deletes them. Nothing happens automatically when a member is removed — their writes are retained and frozen (they lose access with their membership) until an owner or admin makes this call, so shared knowledge is never silently destroyed and never silently inherited. **Owner or admin only** (`org_forbidden`). The subject must actually have left: a current member is refused with `org_member_active`. A user who authored nothing in your organization returns `org_member_not_found` — as does a user id belonging to another tenant, and one that does not exist, so this route cannot be used to probe for accounts elsewhere. The subject's own private `user`-scope memory is never touched; it is theirs, and it is removed with their account.

DELETE /api/v1/memories/{id}: Forget a memory

memory. Deletes one memory by id. A caller may delete exactly what they may write: their own `user`-scope memories, `vault`-scope memories in a vault where their membership grants `can_write` and is not held, and `org`-scope memories if they are an organization owner or admin. Anything else — another tenant's id, another user's private memory, a vault they can only read, organization memory when they are an ordinary member — returns 404, identically to an id that does not exist: a 403 would confirm the id is real.

GET /api/v1/models: List model tiers

models. Returns the public tier catalog (OpenAI-discovery shape) so clients can discover tiers instead of hardcoding them. Never exposes upstream provider model ids. Each tier carries its display name (label: Kavi, Vaani, Mira) and a one-word profile beside it (kavi Deep, vaani Balanced, mira Fast), both stable display text. Each tier's reasoning object gives the reasoning_effort a chat request on that tier gets when it omits one (default_effort) and whether low, medium and high are different depths there (graded; false means on/off, so low thinks as long as high). It is read from the configuration the chat endpoints apply, so it is the live default rather than a description of one.

POST /api/v1/moderations: Classify content (moderations)

moderations. OpenAI-compatible moderations. model must be "cruxy-guard". input accepts a single string or an array of strings — at most 512 strings per request, with the whole body ≤ 64 KiB (either exceeded is a 400). NOTE: the guard model emits labels, not probabilities, so category_scores is binary (1.0/0.0), not calibrated. The response envelope mirrors OpenAI's moderations schema ({id, model, results}) and carries no top-level cruxy.* block: the per-call request_id rides the top-level id instead, formatted "modr-<request_id>" — the same per-call id chat returns as cruxy.request_id. A FLAGGED result additionally carries an additive, OpenAI-inert cruxy block of its own — results[].cruxy.hazard_codes, the raw guard taxonomy codes (S-codes) behind the flag. It is present if and only if flagged is true, so an unflagged result is byte-identical to one from a gateway without the field, and absence must be read as "not flagged", never as "no codes". When present it is never null and may be EMPTY, meaning the guard flagged the input without naming a code. Four codes (S6 specialized advice, S7 privacy, S8 intellectual property, S13 elections) map to no OpenAI category, so a flagged result with every category false is explained by the codes rather than being indistinguishable from a bug. The OpenAI-shaped fields — flagged, categories, category_scores — are unchanged.

GET /api/v1/org/invitations: List the organization's invitations

organization. Lists the invitations the caller's organization has sent — live and settled — with each one's address, role and status. **Owner or admin only** (`org_forbidden`): this is management surface, the same authority that sends them. No invitee user id is reported, for any invitation, whether or not the address names an existing account.

DELETE /api/v1/org/invitations/{invitation_id}: Revoke an invitation

organization. Withdraws an invitation the caller's organization sent, before it is accepted. **Owner or admin only** (`org_forbidden`), resolved before existence — a caller who does not administer the organization is refused whether or not the id is real. An invitation in another organization, one that never existed, one already accepted, declined or revoked, and a malformed id are one `404 invitation_not_found`.

GET /api/v1/org/members: List organization members

organization. Lists the members of the caller's organization — the one their credential names; there is no way to address any other — with each member's role. **Owner or admin only** (`org_forbidden`): this is management surface, and an ordinary member is not entitled to enumerate the roster.

POST /api/v1/org/members: Invite someone to the organization

organization. Invites someone to the caller's organization, addressed by `email` or by `user_id` (exactly one). `role` defaults to `member` and may not be `owner` — ownership is granted with PATCH after the invitee joins. **Owner or admin only** (`org_forbidden`). This creates an INVITATION, not a membership: nobody joins an organization without accepting, and the membership row appears only when they call `POST /api/v1/invitations/{id}/accept`. The `202` response is deliberately identical whether or not the address names an existing account — the gateway never looks it up, so this endpoint cannot be used to discover whether someone has an account. Re-inviting an address that already has a live invitation returns that same invitation unchanged; one whose invitation has lapsed issues a fresh 14-day window. The only distinguishable outcome is `409 org_member_exists`, which tells the caller only what `GET /api/v1/org/members` already shows them about their own organization.

DELETE /api/v1/org/members/{user_id}: Remove an organization member

organization. Removes a member from the caller's organization. **Owner or admin only** (`org_forbidden`); a member whose role is `owner` cannot be removed by anyone. Removal ends access, it destroys nothing: the member's API keys bound to this organization are revoked in the same transaction, and their `org`- and `vault`-scope memory is retained and frozen — reachable by `POST /api/v1/memories/departed/{user_id}`, where an owner or admin decides `keep` or `purge`. Nothing is deleted or reassigned by the removal itself. A user id in another organization, or one that names nobody, returns `404 org_member_not_found`.

PATCH /api/v1/org/members/{user_id}: Change an organization member's role

organization. Changes a member's role. **Owner or admin only** (`org_forbidden`); granting `owner` additionally requires the caller to BE an owner, and the membership it creates is immediately immutable. A member whose role is `owner` cannot be changed by anyone — the org must always retain an owner, so ownership is never demotable. A user id in another organization, or one that names nobody, returns `404 org_member_not_found`.

GET /api/v1/org/memberships: List the organizations I belong to

organization. Lists the organizations the caller is a member of, with their role in each. It takes no organization and no user: the caller is the subject, so there is no parameter by which one person could read another's memberships. This is the list to show when credential issuance answers `org_selection_required`, and the list to read before leaving an organization with `DELETE /api/v1/org/memberships/{org_id}`.

DELETE /api/v1/org/memberships/{org_id}: Leave an organization

organization. Removes the CALLER'S OWN membership from the named organization. Any member may leave; no administrator's permission is involved, which is what makes an unwanted membership recoverable. The organization is named in the path rather than taken from the credential — the only route here that does — because leaving is by definition about an organization your current credential may not name. It can reach no membership but your own. Leaving revokes the caller's API keys bound to that organization, in the same transaction, exactly as removal by an administrator does. It destroys nothing else: memory the caller wrote at organization and vault scope is retained and frozen, for `POST /api/v1/memories/departed/{user_id}` to settle. An **owner** cannot leave (`org_forbidden`): owner rows are immutable so an organization always retains one, and this includes the personal organization every account owns. An organization the caller does not belong to, one that does not exist, and a malformed id are one `404 org_member_not_found`.

GET /api/v1/profile: Get my profile

profile. Returns the authenticated user's account profile: their id, email, `work_type`, whether they have `completed_onboarding`, UTC created/updated timestamps, and the `org` this credential is bound to. `work_type` is `null` until the user answers; otherwise it is one of the closed vocabulary (`engineering`, `data_science`, `product`, `design`, `marketing`, `sales`, `finance`, `operations`, `people_hr`, `legal`, `student`, `other`). The profile is always the caller's own — it is addressed by the credential's subject, never by an id in the path or body — so there is no id to enumerate and no other user's profile this route can return. **`org` is the organization THIS CREDENTIAL is bound to**, with your role in it — not "your organization". A user may belong to several (`GET /api/v1/org/memberships` lists them all); a key names exactly one, stamped at issuance, and it is the one whose shared memory, rules and conversations this credential sees.

PATCH /api/v1/profile: Update my profile

profile. Updates the authenticated user's profile and returns the stored row, in the same shape `GET /api/v1/profile` returns — including the `org` this credential is bound to, which no field here can change. Accepts ONLY `work_type` and `completed_onboarding`, both optional: an **absent** field is left unchanged (it is not reset to a default), so a client can complete onboarding without touching `work_type` and vice versa. `work_type`, when present, must be one of the closed vocabulary (`engineering`, `data_science`, `product`, `design`, `marketing`, `sales`, `finance`, `operations`, `people_hr`, `legal`, `student`, `other`); any other value is `400 invalid_request` and **nothing is written**. An `id` or `email` in the body is ignored — the profile is addressed by the credential's subject, so the write can neither be retargeted to another user nor change identity.

GET /api/v1/prompts: List prompts

prompts. Lists the prompts in the caller's workspace — the one their credential names; there is no way to address any other — each with its slug, latest version number, and total version count (no content). Ordered oldest first.

POST /api/v1/prompts: Create a prompt

prompts. Creates a prompt in the caller's workspace — a named container whose first immutable version holds the given `content`. The workspace is the one the credential names; there is no workspace or org id in the path or body. `name` is required (1..128 bytes). `slug` is the stable, URL-safe handle used to address the prompt and is optional: when omitted it is derived from the name (lowercased, non-alphanumerics collapsed to hyphens); supply it explicitly if the name has no usable characters. A slug already used in the workspace is `409 prompt_slug_exists`. `content` is required (1..102400 bytes) and is stored at rest and returned verbatim — it is deliberately-authored template text and is not PII-filtered. Returns the prompt with its version 1 embedded.

DELETE /api/v1/prompts/{id}: Delete a prompt

prompts. Deletes a prompt and all of its versions (the versions cascade). A prompt id in another workspace or organization, or one that names nothing, returns `404 prompt_not_found`.

GET /api/v1/prompts/{id}: Get a prompt

prompts. Returns one prompt of the caller's workspace with a single version embedded: the latest by default, or the exact version named by `?version=N`. A prompt id in another workspace or organization, or one that names nothing, returns `404 prompt_not_found`. A `?version=N` that the prompt does not have returns `404 prompt_version_not_found` (distinct, because the caller can already see the prompt).

GET /api/v1/prompts/{id}/versions: List prompt versions

prompts. Lists every version of a prompt, newest first, each with its number, content, author, and timestamp. A prompt id in another workspace or organization, or one that names nothing, returns `404 prompt_not_found`.

POST /api/v1/prompts/{id}/versions: Add a prompt version

prompts. Appends a new immutable version to a prompt and returns it. The new version's number is the previous highest plus one; existing versions are never modified. `content` is required (1..102400 bytes). A prompt id in another workspace or organization, or one that names nothing, returns `404 prompt_not_found`.

POST /api/v1/rerank: Rerank documents

rerank. Cohere-style reranking (OpenAI-INCOMPATIBLE). model must be "cruxy-rerank". Scores each document in documents against query and returns them ranked by relevance_score (descending), optionally capped to top_n. At most 512 documents per request, with the whole body ≤ 64 KiB (either exceeded is a 400) — pre-filter a larger candidate set before reranking. The additive cruxy.* block carries the request id and cost.

DELETE /api/v1/rules: Delete organization or vault rules

rules. Removes a ruleset entirely. **The `vault_id` query parameter selects the scope**: omit it to delete the organization's ruleset, or pass a vault id to delete that vault's - equivalent to `DELETE /api/v1/vaults/{id}/rules`. A `vault_id` that is malformed, names nothing, or belongs to another organization is `404 vault_not_found`; the deletion is never quietly applied to the organization's ruleset instead. **Owner or admin of the organization only**, at either scope; vault `can_write` does not grant it. The refusal is `rules_forbidden`. Idempotent: deleting a ruleset that does not exist succeeds, because the end state asked for already holds. Deleting the organization's ruleset leaves every vault's own rules untouched, as it does each vault's inheritance switch; deleting a vault's leaves the organization's untouched and does **not** reset that vault's `inherit_org_rules`. Afterwards the deleted scope contributes no rules to any chat - empty means empty, and no default ruleset takes its place.

GET /api/v1/rules: Get organization or vault rules

rules. Returns a stored ruleset - the ordered standing instructions the gateway prepends to turns in scope. **The `vault_id` query parameter selects the scope**: omit it for the organization's ruleset, or pass a vault id for that vault's own ruleset and its `inherit_org_rules` switch (equivalent to `GET /api/v1/vaults/{id}/rules`). This returns one stored ruleset, NOT the effective merge - for what actually applies to a chat, use `GET /api/v1/rules/resolved`. **Any member may read the organization's**: these instructions are added to the caller's own requests, so they are entitled to see them. A vault's is readable by a vault member granted `can_read` (and not held) and by any owner or admin of the organization; any other `vault_id`, including a malformed one and another tenant's, is `404 vault_not_found`. A scope that has never had rules written and one whose rules were deleted are the same answer, an empty `rules` array - there is no implicit default ruleset. The organization is always the one the credential names; there is no way to address another. **`permissions.can_write` says whether YOU may change this ruleset** - render the edit control from it instead of discovering the answer by attempting a write. It is resolved from the same authority that gates `PUT` and `DELETE` on the scope you selected: owner or admin of the organization, at either scope (vault `can_write` gates a vault's memory, not its rules), so `false` means those requests will be refused with `rules_forbidden` and `true` means they will be admitted. It is per-caller: two members of one organization legitimately see different values for the same ruleset.

PUT /api/v1/rules: Replace organization or vault rules

rules. Replaces a ruleset with the ordered list supplied. **The `vault_id` query parameter selects the scope**: omit it to replace the organization's ruleset, or pass a vault id to replace that vault's - the same scope selector `GET /api/v1/rules/resolved` takes, and the vault form is equivalent to `PUT /api/v1/vaults/{id}/rules`. A `vault_id` that is malformed, names nothing, or belongs to another organization is `404 vault_not_found`; a write is never quietly redirected to the organization's ruleset. **Authority depends on the scope selected.** Both are **owner or admin of the organization only**: the organization's ruleset is prepended to every colleague's every turn, and a vault's to every turn in that vault. Vault `can_write` gates the vault's memory, not its rules. The refusal is `rules_forbidden`, and for a vault the authority is resolved before the vault id is examined, so an unauthorized caller's denial never discloses whether the vault exists. This is a whole-set replace, not a merge: the array sent becomes the ruleset exactly, and sending `[]` clears it. `inherit_org_rules` may be sent only with a `vault_id`, and with one, `rules`, `inherit_org_rules`, or both may be sent; the two are applied in a single transaction. Each rule is one instruction; rules are trimmed of surrounding whitespace and an empty one is rejected. Three size caps apply and are enforced by the database, because rules are re-injected on every request in scope and so cost context and tokens on every call: at most 50 rules, at most 1000 characters per rule, and at most 8000 characters in total. Exceeding any of them is `400 rules_too_large`, naming which cap. **Content carrying obvious PII is refused, not redacted**: a rule containing an email address, a phone number, or a Luhn-valid card number fails with `pii_rejected` and nothing is stored - rules have no expiry and are transmitted on every request in scope. The error names the category and the rule's position, and never echoes the matched text.

GET /api/v1/rules/resolved: Resolve the effective ruleset

rules. Returns the rules that actually apply to a chat context, already merged - the single server-side answer, in a shape a client renders read-only. Pass `vault_id` for a chat inside a vault; omit it for a chat with none. The merge: with no vault, the organization's rules apply. Inside a vault that inherits (the default), the organization's rules apply first and then the vault's own. Inside a vault that does not inherit, only the vault's own rules apply. Every rule carries the `source` it came from (`org` or `vault`) so an unexpected instruction is traceable to the ruleset that contributed it. **Empty means empty** - every branch can legitimately return zero rules, and zero rules is a real answer, not a missing one; there is no implicit or default ruleset anywhere. Any member may call this. A `vault_id` the caller cannot read is `404 vault_not_found` and never a silent fallback to the organization's rules, so a mistyped id is reported rather than answered for the wrong context.

GET /api/v1/service-token/self: Describe the calling service token

auth. Returns the authority of the service token making the request — its service name, the endpoint families it may call, whether it is read-only, and when it expires. It describes ONLY the presented credential: the endpoint accepts no parameters, so there is no way to ask about another token, and it reveals nothing about any other token, user or organization. Intended as a preflight for long-running operator scripts that must not straddle their own credential's expiry. `allowed_scopes` is always the effective list and is never empty — a token issued without `--scopes` holds every family and lists them all. `expires_at` is null for a token that never expires, and is never in the past (an expired token cannot authenticate). To compute remaining lifetime without trusting the client clock, subtract the response's `Date` header from `expires_at`.

POST /api/v1/tokens/count: Count input tokens

tokens. Returns the number of input tokens POST /api/v1/chat would bill for the same model, messages, and tools, computed locally with no upstream inference call — so this endpoint is not billed and records no usage. model is an alias only (kavi, vaani, or mira); "auto" and any base-model id are rejected. messages use the same shape as /chat, and `tools` (the same shape /chat accepts) IS counted — a tool harness is real billed input, so omitting it from the body returns a count for a request you are not making. `tool_choice` does not affect the count and is not read. The count includes the server-injected per-tier system prompt and the tier's chat-template scaffolding — including the generation prompt's reasoning block, which follows the tier's DEFAULT reasoning mode (what /chat sends when the client does not override reasoning_effort) — so it matches /chat's usage.input_tokens (prompt_tokens) for the same input. It is EXACT for single-turn and user-only prompts on every tier, and for all shapes on vaani and mira. It is a conservative UPPER bound — never an undercount — in two cases where the tier's template renders the prompt more compactly upstream than can be reproduced offline: a kavi conversation with prior assistant turns (up to ~6 tokens per prior turn), and message content with leading or trailing whitespace on vaani or mira (up to ~3 tokens per such message). A /chat request that overrides reasoning_effort shifts prompt_tokens by 1–2 tokens (the reasoning-block delta), which this parameter-free count does not model. Authenticated and rate-limited per API key (its own bucket), emitting the same X-RateLimit-* and Cruxy-Version headers as the other endpoints. Idempotency-Key is not honored (the endpoint is a pure function with no side effects). Counting is REFUSED, with 400 input_not_countable and no count in the body, for text carrying a single unbroken run of more than 1024 bytes (a run of letters, whitespace or punctuation with no word, digit or punctuation boundary in it) or for more than 262144 bytes of text in one request: tokenizing is quadratic in the length of one such run, and returning a partial count would be the undercount this endpoint promises never to return. Ordinary prose, source code, JSON and CJK text are far inside both bounds.

GET /api/v1/vaults: List vaults

memory. Lists the vaults the caller can use — those where they hold a membership that is not held. Each entry reports the caller's own permissions — including `can_administer`, the structural authority to delete the vault or hold its members, which is resolved from the same predicate as on GET /api/v1/vaults/{id} and cannot disagree with it — and whether the caller has pinned it. Organization admins are not listed into vaults they are not members of: administering an organization does not grant access to its teams' shared memory. Two optional filters, both resolved server-side: `scope` (all | owned | shared — owned means the caller owns it, shared means they participate but do not own it) and `archived` (false, the default, lists only live vaults; true lists only archived ones). With no parameters the listing is all non-archived vaults.

POST /api/v1/vaults: Create a vault

memory. Creates a shared-memory vault inside the caller's organization. The caller becomes the owner and is added as a member with read, write, and share; the returned `permissions` therefore reports all three, and `can_administer` true, since the owner may delete the vault and hold its members. Vault names are unique within an organization. Optional `description`, `icon`, `color`, and `memory_scope` may be set; an invalid icon, color, or memory_scope is rejected with 400 invalid_request. `memory_scope` is enforced in memory resolution: `vault_only` isolates the vault's memory both ways — its rows are withheld when resolving outside it, and a chat inside it resolves only that vault's memory (organization and user scope excluded). `default` participates in the normal organization > vault > user merge. The create is IDEMPOTENT when the request carries `external_id`, your own system's identifier for the vault: a second create naming an `external_id` this organization has already seen returns the existing vault with **200** and `created: false`, and writes nothing — not the name, so a create is never a rename. A first create answers **201** with `created: true`. Send it whenever a retry is possible: a duplicate NAME is refused with 409 vault_name_exists, which carries no vault id, so a client whose request timed out cannot learn which vault it made — and if the vault has been renamed since, the retry does not collide at all and creates a second one holding a slice of the same team's memory. Reusing an `external_id` that names a vault somebody else owns is 409 vault_external_id_conflict.

DELETE /api/v1/vaults/{id}: Delete a vault

memory. Deletes a vault, its membership list, and all memory stored in it. Owner or organization admin only; a member — even one with write and share — gets 403 vault_forbidden. This is exactly what `permissions.can_administer` reports on the vault reads: the field and this gate are the same predicate, so a caller that saw `can_administer: false` will be refused here, and one that saw true will not be refused for want of authority.

GET /api/v1/vaults/{id}: Get a vault

memory. Returns one vault and the caller's own permissions in it. `permissions` carries `can_read`, `can_write` and `can_share` — the caller's grants over the vault's CONTENT — plus `can_administer`, which is structural authority over the CONTAINER: whether they may delete this vault and hold or restore its members. The four do not imply one another: a member with all three grants does not administer the vault, and an organization admin administers it while possibly holding no grants at all. Every one is resolved from the same predicate that gates the corresponding request, so a client may gate its controls on this block and on nothing else. A vault in another organization, a vault the caller is not a member of, and a vault where the caller is held all return 404 — a 403 would confirm the id is real.

PATCH /api/v1/vaults/{id}: Update a vault's metadata

memory. Changes a vault's `name`, `description`, `icon`, `color`, and/or `memory_scope`. Every field is optional but at least one must be present; an omitted field is left unchanged, while an explicit `null` clears a nullable presentation field (`description`, `icon`, `color`). `name` and `memory_scope` are non-null and can only be set to a value. The same closed sets as create apply — `icon` and `color` from their fixed vocabularies, `memory_scope` in `default | vault_only`, `name` 1..128 bytes, `description` at most 2000 — and an invalid value is 400 invalid_request with nothing written. Renaming to a name already taken in the organization is 409 vault_name_exists. Requires `can_write`: a participant without it gets 403 vault_forbidden, and a non-member, held, or cross-organization caller gets 404 vault_not_found. Returns the enriched vault shape, with `updated_at` advanced and the caller's own `permissions` — including `can_administer` — re-resolved.

DELETE /api/v1/vaults/{id}/archive: Restore a vault

memory. Clears a vault's archive, returning it to the default list and reporting `archived: false`. Idempotent: restoring a live vault is 204. Requires `can_write`: a participant without it gets 403 vault_forbidden, and a non-member, held, or cross-organization caller gets 404 vault_not_found.

PUT /api/v1/vaults/{id}/archive: Archive a vault

memory. Archives the vault: it is hidden from the default vault list and appears only with `?archived=true`, and GET reports `archived: true`. The container and all its memory are untouched — archiving is not deletion. Idempotent: archiving an archived vault is 204 and does not move the original archive instant. Requires `can_write`: a participant without it gets 403 vault_forbidden, and a non-member, held, or cross-organization caller gets 404 vault_not_found.

GET /api/v1/vaults/{id}/members: List vault members

memory. Lists the vault's members and each one's permissions, including any held members with their suspended permissions intact — the point of a reversible hold is that what it suspends stays visible to whoever may lift it.

POST /api/v1/vaults/{id}/members: Add a vault member

memory. Adds a member of the caller's organization to the vault with the given permissions. The vault owner and organization admins may grant anything; a member with `can_share` may grant only permissions they themselves hold — a member who cannot write cannot create a member who can. Permissions default to false, so omitting one grants nothing. A user id outside the caller's organization returns 404 user_not_found.

DELETE /api/v1/vaults/{id}/members/{user_id}: Remove a vault member

memory. Removes a member from the vault. Their access ends immediately — the next request reads the vault as if it did not exist. Owner, organization admin, or a member with `can_share`. The vault owner cannot be removed; delete the vault instead.

PATCH /api/v1/vaults/{id}/members/{user_id}: Update a vault member's permissions

memory. Changes a member's read/write/share permissions. Omitted fields keep their current value. The no-escalation rule is applied to the RESULTING permission set, not just the changed fields: a member with `can_share` but not `can_write` cannot edit a row that grants write, even if they are not the one who granted it. `held` is not accepted here — it is a separate authority with its own route. The vault owner's own membership cannot be modified by anyone.

PUT /api/v1/vaults/{id}/members/{user_id}/held: Suspend or restore a vault member

memory. Sets or clears a member's hold. While held, that member's read, write, and share are all inert regardless of their flags, and the vault reads to them as if it did not exist. The flags themselves are untouched, so clearing the hold restores exactly the permissions they had. Owner or organization admin only — suspending is the revocation of someone else's grant, not a grant, so `can_share` does not confer it. The owner cannot be held. This authority is reported as `permissions.can_administer` on the vault reads, from the same predicate that gates this route.

DELETE /api/v1/vaults/{id}/pin: Unpin a vault

memory. Removes the CALLING user's pin on the vault. Idempotent: unpinning a vault that was not pinned is 204. Requires only that the caller can read the vault; one they cannot read is 404 vault_not_found. Never affects another member's pin.

PUT /api/v1/vaults/{id}/pin: Pin a vault

memory. Pins the vault for the CALLING user — a personal bookmark that surfaces on the `pinned` field of GET and the vault list, and never visible to other members. Idempotent: pinning an already-pinned vault is 204, not a conflict. Requires only that the caller can read the vault (a bookmark is not an edit); a vault the caller cannot read — non-member, held, no `can_read`, or another organization — is 404 vault_not_found.

DELETE /api/v1/vaults/{id}/rules: Delete a vault's rules

rules. Removes a vault's own ruleset. **Requires owner or admin of the organization** (`rules_forbidden` otherwise), resolved before the vault id is examined. Vault `can_write` does not grant it. Idempotent: deleting a ruleset that does not exist succeeds. The vault's `inherit_org_rules` switch is deliberately **not** reset - clearing the rules a vault adds and changing whether it inherits the organization's are different intentions, and doing the second while asked for the first would silently start or stop applying the organization's rules to that vault.

GET /api/v1/vaults/{id}/rules: Get a vault's rules

rules. Returns a vault's own ruleset and its `inherit_org_rules` switch. Readable by any member of the vault granted `can_read` (and not held), and by any owner or admin of the organization (who may set it, and so can always read back what they set). A vault in another organization, one that does not exist, one the caller does not participate in, and one where the caller is held all return the same `404 vault_not_found`. `inherit_org_rules` is a property of the vault and is returned whether or not the vault has any rules of its own. **`permissions.can_write` says whether YOU may change this vault's rules** - render the edit control from it rather than inferring write access from a failed write. It is `true` for an owner or admin of the organization and `false` for everyone else; vault `can_write` gates the vault's memory, not its rules. It is resolved from the same predicate the `PUT` and `DELETE` use, so it cannot disagree with them. It is per-caller.

PUT /api/v1/vaults/{id}/rules: Set a vault's rules and inheritance

rules. Sets a vault's own ruleset and/or its `inherit_org_rules` switch. **Requires owner or admin of the organization** (`rules_forbidden` otherwise), for both halves of this request: a vault's rules and its inheritance switch. Vault `can_write` gates the vault's memory and does not grant it. Authority is resolved before the vault id is examined, so an unauthorized caller's denial never reveals whether the vault exists. Both fields are optional but at least one must be present, and **both are applied in a single transaction** - turning inheritance off and installing the vault's replacement rules is one change, and splitting it across two requests would leave a window in which chats in the vault run with no rules at all. `inherit_org_rules` true (the default for every vault) means the organization's rules apply **in addition to** this vault's, organization rules first; false narrows chats in this vault to its own rules, which may be none. It never makes the vault's own rules inert. The same caps and the same PII gate as the organization ruleset apply.

GET /api/v1/workspaces: List workspaces

organization. Lists the workspaces of the caller's organization — the one their credential names; there is no way to address any other — each with its slug, monthly spend cap (null when uncapped), and whether it is the reserved default. The default workspace is listed first. **Owner or admin only** (`workspace_forbidden`): this is management surface, not a directory.

POST /api/v1/workspaces: Create a workspace

organization. Creates a workspace inside the caller's organization — a team's isolated environment and spend budget under that org. **Owner or admin only** (`workspace_forbidden`). `name` is required (1..128 bytes). `slug` is the stable, URL-safe handle used to address the workspace and is optional: when omitted it is derived from the name (lowercased, non-alphanumerics collapsed to hyphens); supply it explicitly if the name has no usable characters. A slug already used in the organization is `409 workspace_slug_exists`, and the reserved `default` slug cannot be minted (`400 invalid_request`). `spend_cap_micros` is an optional monthly ceiling in micro-USD (positive; omit or null for uncapped) enforced by the chat/usage gates against the workspace's summed cost.

DELETE /api/v1/workspaces/{id}: Delete a workspace

organization. Deletes a workspace. **Owner or admin only** (`workspace_forbidden`). A workspace that holds — or has ever held — a credential is refused with `409 workspace_not_empty`: a credential's tenancy and its usage history must never be orphaned, so a workspace is deletable only while it never held a key. The reserved default workspace is never deletable (`409 workspace_is_default`). A workspace id in another organization, or one that names nothing, returns `404 workspace_not_found`.

GET /api/v1/workspaces/{id}: Get a workspace

organization. Returns one workspace of the caller's organization. **Owner or admin only** (`workspace_forbidden`). A workspace id in another organization, or one that names nothing, returns `404 workspace_not_found` — a 403 would confirm the id is real.

PATCH /api/v1/workspaces/{id}: Rename or re-cap a workspace

organization. Changes a workspace's `name` and/or its `spend_cap_micros`. Every field is optional but at least one must be present; an omitted field is left unchanged, an explicit `null` cap clears the cap (uncaps), and a positive cap sets it. The `slug` is a stable handle and is NOT changed here. Applies to any workspace including the default (only delete singles the default out). **Owner or admin only** (`workspace_forbidden`). A workspace id in another organization, or one that names nothing, returns `404 workspace_not_found`. Returns the updated workspace.

POST /api/v2/chat: Chat completions (native v2)

chat. Cruxy-native chat completions. model is a public tier - kavi, vaani, or mira - or auto to let the server-side router pick the tier from the request (accepted natively; no client-side shim). stream is optional and false by default. With stream:true the response is a text/event-stream of NAMED events: `start` (the served tier and how it was routed), zero or more `reasoning` (fragments of the model's thinking trace, only when the tier reasons - reasoning_effort above none; NOT answer content, no token count of its own, since usage output tokens already include it undivided; ignore it if you do not render thinking), one or more `delta` (each a text `content` fragment OR `tool_calls` fragments), then exactly one terminal frame - `finish` (finish_reason plus the final native usage) on success, or `error` (a stable, client-safe error code + message + request_id, from the same registry as the JSON error envelope) on a failure AFTER the stream opens. There is NO `[DONE]` sentinel and NO silent truncation: a v2 stream ALWAYS ends with a `finish` or an `error` frame. With stream:false or stream omitted the response is the native completion object - the assistant reply is the top-level message with a sibling finish_reason, a first-class routing object (tier, mode, reasoning_effort), a top-level request_id, usage carrying the native token counts and cost, and - only when the tier reasoned - a top-level reasoning string holding the thinking trace (kept out of message on purpose: do not send it back as conversation). TIMEOUTS AND RETRIES. With stream:false the gateway makes ONE attempt at the model, bounded by the tier's deadline: kavi 90s, vaani 90s, mira 45s. An attempt that runs past it is cancelled and answered 504 upstream_timeout, and nothing is billed. The gateway does NOT retry a timeout, since a turn that needed longer needs longer again: send stream:true (a stream may run for up to five minutes), or ask for less with a lower max_tokens or reasoning_effort. Other upstream failures (an overloaded or failing model, a dropped connection) are retried inside the gateway a bounded number of times, with backoff, before it answers 503 upstream_overloaded or 503 model_unavailable; on stream:true that happens only while the stream is being opened, never after a byte has reached you. The gateway never moves a call to another tier once it is sent: model auto picks a healthy tier before the call, and an explicit tier that kept failing is refused at once with 503 model_unavailable while it cools down. REASONING SPENDS max_tokens: with reasoning_effort omitted each tier applies its default - kavi high (thinking on), vaani low, mira none; auto chooses per request - reported as routing.reasoning_effort, and the thinking is generated inside the same max_tokens budget as the answer. A small max_tokens on kavi can therefore be spent entirely on thinking, ending with finish_reason "length" and an empty message; send reasoning_effort:"none" for a short direct answer, or leave max_tokens unset (the tier's cap) to keep reasoning - no OpenAI choices[] array and no cruxy.* wrapper. WHICH CREDENTIALS USE MEMORY: memory recall and automatic capture are ON by default for a credential a person gets by signing in (the Cruxy web app, desktop, the CLI's device login, mobile) and OFF by default for a developer API key or a headless key, which opts in per request with memory:{"recall":true} and/or memory:{"capture":true} (see PER-REQUEST MEMORY CONTROLS below). A key minted by POST /api/v1/device/token or POST /api/v1/auth/oauth/token is a signed-in credential whichever client ran the flow, a third-party tool included, so it has memory and rules on; a server must use a developer API key instead (see BearerAuth). GET /api/v1/limits reports the kind as bucket: subscription is signed in, apikey and headless are not. An API key often serves people other than its owner, and recall would put the owner's stored memory into their turns while capture would store what they say, so neither happens unless the request asks. A key whose allowed_scopes omit the memory family can never use memory through chat - asking with true is 403 key_scope_forbidden (required_scope memory), refused before anything is billed - which makes it the key to issue for a service that must never see anyone's memory. When recall runs, relevant stored memories are recalled server-side and added to the turn as background context, drawn from the same resolved view GET /api/v1/memories lists: personal memory, the organization's, and readable vaults'. The optional vault_id names the vault a chat is happening inside and selects the ISOLATED case - inside a `vault_only` vault recall draws on ONLY that vault's memory, excluding personal and organization memory. A vault_id the caller cannot read is treated as none and never reported. What was recalled rides the `start` frame and the non-streaming body as a top-level memory_recall (scope - personal, org, or vault - and key per memory; never the stored value), with a vault-scope memory naming its source vault (vault_id, and vault_name when it resolves). mentioned_vault_ids lets a turn reference vaults it is not inside (at most 3, ids resolved by the client's picker); unreadable ids, repeats, and the active vault are dropped silently, and an accepted mention adds that vault's memory to the same recall budget without changing isolation or making the vault writable. WHICH CREDENTIALS GET STANDING RULES: the same default as memory. Your organization's standing rules are injected by default for a credential a person gets by signing in, and NOT for a developer API key or a headless key, whose rules are its owner's organization's guidance and which a visitor to whatever the key serves could ask the model to repeat. Such a key opts in per request with rules:true, or once, on the key, by being issued allowed_scopes that NAME the rules family (an unscoped key has every family but has not opted in). Any credential may send rules:false to decline them for a turn, which needs no scope since the user's own message already overrides them. A key whose allowed_scopes omit rules never gets them, and rules:true on it is 403 key_scope_forbidden (required_scope rules), refused before anything is billed. The switch covers the whole resolved ruleset, the organization's and, with vault_id, the vault's, so a turn gets exactly what GET /api/v1/rules/resolved returns or nothing. When rules apply and the caller's organization has configured them they are resolved server-side and injected as guidance for the turn - DEFAULTS the user's own message overrides - and both the `start` frame and the non-streaming body carry an additive top-level rules_applied signal (counts and sources, never rule text). vault_id selects which rules apply too: inside a vault the turn gets that vault's own ruleset merged with the organization's per the vault's inherit_org_rules switch - the same answer GET /api/v1/rules/resolved gives for that vault - and rules_applied names each contributing scope (org, vault). A vault_id the caller cannot read is treated as none here as well, so the turn simply runs with organization-scope rules. Injected rule text is counted in the usage input tokens like any other input. When capture runs, after a turn is answered the gateway may also capture a durable fact from the exchange into memory; every capture is reported additively as a top-level memory_capture - on the non-streaming body, and on the terminal `finish` frame when streaming, since what was captured is only knowable once the answer is complete. It names the scope and key of each stored memory and never the value, so nothing is stored that the response does not report. Capture writes personal memory, or the vault named by vault_id when you may write it (a read-only vault demotes to personal), never organization-wide memory; obvious personal data is dropped rather than stored; and a failed capture never affects the turn. PER-REQUEST MEMORY CONTROLS: an API key opts in with memory:{"recall":true} (inject the stored memories most relevant to this turn) and/or memory:{"capture":true} (allow a durable fact from this turn to be stored); any credential may send memory:{"recall":false} to inject no stored memory into this turn (no memory is read, mentioned_vault_ids are not consulted, no memory_recall is returned, and input tokens fall by the size of the block that would have been injected), and memory:{"capture":false} to store nothing from it (no extraction, no write, no memory_capture). The two sides are independent: {"capture":false} alone keeps the turn personalised by what is already stored while storing nothing new. A missing memory object, a missing key, or null means the credential's default (on for a signed-in client, off for a developer or headless API key), and a literal true or false states your choice. false needs no key scope, since it only makes the gateway do less for your own request; true needs a key whose allowed_scopes include memory (an unscoped key has every family). Standing rules are not memory, and this field never affects them: they have their own switch, rules (see WHICH CREDENTIALS GET STANDING RULES above). Pin Cruxy-Version 2026-09-26 on every request that relies on memory:false: unknown fields are ignored rather than refused, so a gateway that predates this field would answer 200 and capture anyway, and a pinned request is instead refused by such a gateway with 400 unknown_version before recall, the upstream call, billing or capture run. Dated versions are otherwise only for breaking changes; this date gates an additive field because its silent absence on an older gateway would make the response misrepresent what it used (here, a privacy promise; content the user supplied qualifies on the same terms), and it is not precedent for ordinary additive fields. ATTACHED FILES: a user message may carry file_ids, the ids of files you uploaded with POST /api/v1/files under the same organization and user as this credential, and the gateway reads each one into the turn. The request must pin Cruxy-Version 2026-10-05 or later: a request naming file_ids without that pin is 400 invalid_request (carrying required_version), because a gateway that predates the field would ignore it and answer without the files, and the pin is what makes such a gateway refuse instead. UTF-8 text files are read (plain text, markdown, CSV, JSON, source code), and so are PDFs and Word documents (.docx), as the text read out of them once, when they were uploaded (POST /api/v1/files reports it as text_status): a document is framed, measured against the caps and billed by its text exactly as a text file is, and is never parsed on a turn. A file of another type, or whose bytes are not UTF-8 text, is 400 file_not_text, and so is a document whose text could not be read, with a reason field saying why: no_text (no text found, such as a scanned PDF), encrypted (password-protected), corrupt, unreadable (reading it went past the time or memory allowed for one document), legacy_doc (a Word 97-2003 .doc) or not_extracted (uploaded before documents were read; upload it again). A file over the per-file token cap, or one that takes the request past the per-turn cap, is 400 file_too_large, and so is a PDF of more than 500 pages (bound pages) or over 64 MiB (bound bytes), which is not read; for a document the message advises attaching a shorter excerpt. Both name the file (file_id, filename, and for file_too_large bound, limit and tokens). An id that is not yours, whether another user's, one filed under another of your organizations, missing or malformed, is 404 file_not_found, the same answer in every case. A key whose allowed_scopes omit files is 403 key_scope_forbidden (required_scope files). All of these are answered before anything is sent upstream or billed. Each file's text goes in as a user-role block immediately before the message that named it, framed as material for that message and never as instructions to follow; it never enters the system prompt, recall does not search with it, and automatic capture does not read it. A file named on several messages is sent once, before its first mention. Its tokens are input like any other and are billed on EVERY request that sends the file, wherever its block sits: a file id you keep in a conversation's history is billed on each turn, not only the first. They count toward the tier's context window: a file on an earlier message that does not fit beside the conversation, with room left for the reply's max_tokens and a margin of a tenth of the window, is left out, oldest first, and listed in files_omitted with reason context_window, and a turn that does not fit even with every earlier file left out is 400 context_overflow. POST /v1/chat/completions refuses file_ids, and POST /api/v1/tokens/count does not count them.

POST /v1/chat/completions: Chat completions (OpenAI-compatible)

openai. OpenAI-compatible chat completions, request and response. Point any OpenAI client or evaluation harness at base URL https://api.cruxy.ai/v1 with your Cruxy API key as the bearer token and it works unmodified. `model` is a public Cruxy tier — kavi, vaani, or mira — or auto to let the server-side router pick; an upstream provider model is never accepted or returned. With stream:false the response is a standard chat.completion object: id, object, created, model, choices[0].message, and usage with prompt_tokens / completion_tokens / total_tokens. With stream:true it is OpenAI's SSE protocol: chat.completion.chunk frames (a role primer, content or tool_call deltas, then a frame carrying finish_reason), terminated by the literal `data: [DONE]`. The terminal choices:[] usage frame is emitted only if you send stream_options:{"include_usage":true}, exactly as OpenAI gates it. If the upstream fails AFTER the stream has opened, the stream ends with a `data: {"error":{...}}` frame instead of `[DONE]`, so a truncated answer is never mistaken for a finished one. Errors use OpenAI's envelope, {"error":{message,type,param,code}}, where `code` is the stable Cruxy error code. `temperature`, `top_p`, `max_tokens` and `reasoning_effort` are honoured (max_tokens is clamped to the tier's own output cap; reasoning_effort is one of none, low, medium, high). As on OpenAI's reasoning models, thinking is generated inside the max_tokens budget and is not returned as content, and kavi reasons by default (reasoning_effort omitted means kavi high, vaani low, mira none): a small max_tokens on kavi can be spent entirely on thinking and return finish_reason "length" with empty content. Send reasoning_effort:"none" for a short direct answer, or leave max_tokens unset. TIMEOUTS AND RETRIES. With stream:false the gateway makes ONE attempt at the model, bounded by the tier's deadline: kavi 90s, vaani 90s, mira 45s. An attempt that runs past it is cancelled and answered 504 upstream_timeout, and nothing is billed. The gateway does NOT retry a timeout, since a turn that needed longer needs longer again: send stream:true (a stream may run for up to five minutes), or ask for less with a lower max_tokens or reasoning_effort. Other upstream failures (an overloaded or failing model, a dropped connection) are retried inside the gateway a bounded number of times, with backoff, before it answers 503 upstream_overloaded or 503 model_unavailable; on stream:true that happens only while the stream is being opened, never after a byte has reached you. The gateway never moves a call to another tier once it is sent: model auto picks a healthy tier before the call, and an explicit tier that kept failing is refused at once with 503 model_unavailable while it cools down. Fields this gateway does not implement — n, stop, seed, presence_penalty, frequency_penalty, logit_bias, logprobs, user and the rest — are IGNORED rather than rejected, so a harness that sends them still runs. This endpoint deliberately carries none of the Cruxy-native chat features: no cruxy.* block, no vault_id, no memory recall, no automatic memory capture, and no injection of your organization's standing rules — a run here is your messages and the tier, nothing else, which is what makes it reproducible as a benchmark. For those features use POST /api/v1/chat (OpenAI-shaped SSE, Cruxy-flattened body) or POST /api/v2/chat (the Cruxy-native wire). Authentication, rate limits, the endpoint-scope check and billing are identical on all three: this route requires the `chat` endpoint family and its usage lands on the same ledger.

GET /v1/models: List models (OpenAI-compatible)

openai. OpenAI-compatible model listing, for clients and evaluation harnesses pointed at this gateway as an OpenAI endpoint (base URL https://api.cruxy.ai/v1). Returns {"object":"list","data":[{id, object, created, owned_by}]} and nothing else — the ids are the public Cruxy tiers (kavi, vaani, mira) and never an upstream provider model. For the richer catalog — context window, max output, tool support, per-tier pricing, latency targets and reasoning defaults — use GET /api/v1/models, which lists the same tiers with Cruxy's own discovery metadata. Errors on this path use OpenAI's envelope ({"error":{message,type,param,code}}); the /api/v1 surface keeps the Cruxy envelope.

CruxyLearn

© Cruxy

Platform

PlatformStatus

Documentation

API referenceChangelogllms.txtGitHub