/api/ responses are JSON; successful mutations may return an empty
body, and previews, downloads, thumbnails and avatars return bytes. Browser
routes serve HTML or redirect. Every request gets an X-Request-Id header —
carry it into bug reports. See Errors for the JSON error contract.
Endpoint index (compact)
Auth
The same authentication chain accepts browser sessions and API credentials:- Cookie — set by local-password JSON login, the
POST /loginform, or the OIDC callback. OIDC configuration disables local bootstrap/password login and redirects anonymous browser entry points to the provider. Cookies are HttpOnly + SameSite=Lax; Secure whenPUBLIC_URLuses HTTPS. - API token —
Authorization: Token <hex>is preferred;Authorization: Bearer <hex>is also accepted. Suchi tokens are exactly 64 lowercase hex characters. Other Bearer values go to the OIDC authenticator.
email and the boolean email_verified: true.
This applies to direct Bearer authentication and browser sign-in; see
provider requirements. Invalid credentials do not fall back to
an existing browser session.
API tokens are limited by an explicit route allowlist in
distro/cmd/suchi/serve_token_policy.go. Document reads (including search,
chat, previews, and shared vocabulary) require documents:read; document
mutations require documents:write; the activity feed requires events:read.
Write scope does not imply read scope, and scopes never bypass ownership,
role, capability, or document-visibility checks.
Account/settings administration, API-token management, credential vaults,
taxonomy administration, profile management, raw-original downloads (raw=1), and profiling require a
browser/OIDC session. Unlisted routes fail closed for API tokens with
403 token_route_forbidden; a listed route with a missing scope returns
403 insufficient_scope. /metrics retains admin-token support. New HTTP routes
must explicitly declare their token access at the assembly boundary.
Filing-system request context
After first prefixed import, collection requests select?system=S01.
Without it, session collections use original system 1, never an arbitrary accessible
system or server-wide “last selected” state. Tokens use their bound system as default
and ceiling. Unqualified numeric item requests derive the actual object’s system;
an explicit mismatched system is not ignored, even for an administrator.
All document/object access also needs an active account, system entry and existing
object permission. Unknown, inaccessible or mismatched systems return 404
system_unavailable; unavailable objects return 404 not_found, without foreign
labels. Ordinary authentication/role failures remain 401/403. Metadata, query name/
code resolution, saved views, mailboxes, passwords, automations, workflows, jobs,
counts, events and research are scoped before hydration/pagination. Identity/groups,
global capabilities and server configuration remain instance-wide.
GET /api/jd/systems returns
{introduced,default_system_code,results:[{code,name,is_default}]}. Before
introduction the list may be empty. Afterwards it contains only enterable systems
(a token only its binding), sorted by code; the default code is empty if the
original system is unavailable. Both this route and address resolution accept
documents:read tokens.
GET /api/jd/resolve?address=S01.13.147 returns
{id,system_code,jd_address} only for the authorized live document’s current
system/category. Invalid address syntax or ID overflow is 400 bad_address;
unknown, stale or inaccessible locations are 404. .ID is the existing positive
installation-global document ID, not an authored/category-local counter.
Document projections and upload receipts expose system_code and jd_address
after introduction; category system_code is distinct from its protected system
boolean. Numeric API IDs do not change.
Session admins manage direct membership with
GET/PUT /api/admin/jd/systems/{code}/members, representation {"user_ids":[...]}.
PUT replaces the explicit set: preserve existing inactive members unless removing
them deliberately; duplicate/unknown IDs and new grants to inactive users fail.
Admins enter implicitly; their access is not an editable membership grant.
PATCH /api/admin/jd/systems/{code} accepts {"name":"..."}, nonblank and at most
80 Unicode characters. Codes cannot change; there is no enable/disable/delete endpoint.
Removing membership atomically revokes that member’s system shares, tokens and
pending mobile pairings. Re-adding membership does not revive those credentials.
Existing ACL rows remain ineffective without membership; re-admission can make
retained ACLs useful again. New user ACL grants require the recipient to be active
and enter the object’s system (member or admin), checked in the caller’s write
transaction; granting a document to a nonmember fails rather than granting entry.
Writes and receipt replay recheck current authority in the writer transaction;
already delivered bytes cannot be retracted.
See Permissions for the shared administrator/host/backup trust boundary.
Token creation and mobile pairing capture the selected system. Password exchange
at /api/token/?system=S01 accepts a target query code; omission uses original
system 1. Pairing exchange consumes the code and issues its bound token in one transaction.
Changing a device’s system needs a new pairing/token; scope names do not broaden it.
Endpoints (selected — see index above for the full list)
The sections below group routes by task. Auth and filing-system request context apply throughout.Health and client compatibility
GET /healthz
Process-alive. Cheap. Never touches the DB. Suitable for k8s
liveness probes.
200 {"status":"ok"}
GET /readyz
DB-reachable + schema at the expected user_version. Suitable for
load-balancer readiness gating.
200 {"status":"ok","user_version":<int>}
503 {"status":"db_unreachable"|"schema_stale", ...}
GET /metrics
Prometheus exposition. Admin auth required: anonymous requests return 401
and authenticated non-admin requests return 403. Per-route request counts and
job-queue depth are internals a public scraper should not see. Send
Authorization: Token <admin-token> on scrapes.
When SUCHI_PPROF=1, /debug/pprof/* requires an admin browser/OIDC session
(API tokens are not accepted). Health probes (/healthz, /readyz) stay public.
Metrics include:
suchi_http_requests_total{method,route,status}suchi_http_request_duration_seconds{method,route}(histogram)suchi_jobs_pending,suchi_jobs_running(gauges)suchi_jobs_retries_total{kind},suchi_jobs_dead_total{kind}- Standard Go runtime + process collectors.
GET /api/handshake
Public compatibility probe for first-party clients. Call it before sending
credentials. The response contains no deployment or user state:
200 {"product":"suchi","api_version":1,"min_app_version":"0.1.0","pairing_device_name":true}
Clients must require product to equal suchi, require an API version they
support, and compare min_app_version as numeric semantic-version components.
Redirects are not compatibility responses and must not receive credentials.
pairing_device_name: true advertises support for naming a connection during
code exchange. Clients omit device_name unless this flag is true; older
servers may reject unknown request fields.
Authentication and accounts
POST /api/login
Local-password browser login. Send a JSON body; success plants a session cookie
and returns 204. API clients should exchange the same credentials at
POST /api/token/, which returns {"token":"<hex>"} without creating a
browser session or setting a cookie.
The separate POST /login browser form accepts URL-encoded fields and redirects
after setting the same kind of session cookie.
401 on bad credentials. One error path covers every failure mode.
Credential routes and demo session creation share their respective rate-limit
buckets across plain, trailing-slash and percent-encoded slash paths.
GET /oidc/login / GET /oidc/callback
OIDC login flow. Only registered when OIDC is configured. State token
is a signed cookie; the callback plants a session cookie via
local-auth’s IssueSession (one code path mints all cookies).
POST /api/logout
Revokes the current browser session and, when the request authenticated with an
API token, that token. The SPA calls this endpoint before returning to the login
screen.
204 No Content on success. Logging out is idempotent.
GET /api/whoami
Returns the authenticated Principal + profile fields. The SPA reads
display_name for the topbar avatar menu and avatar_url for the
image (initials fallback until an avatar is set).
build_version identifies the running server; build_revision is its short
source revision when known, with .dirty for locally modified source. These
read-only fields are not user profile settings.
display_name + avatar_url are omitted when unset. instance_host comes
from the configured PUBLIC_URL and lets the SPA preview the identity shown on
public share pages. 401 if anonymous.
scopes copies the authenticated API token’s granted scopes. Cookie/OIDC
sessions return []; clients must not infer token grants from user role or
capabilities.
POST /api/mobile/pairing
Create a five-minute mobile pairing from a browser/OIDC session. API tokens and
demo identities cannot create or cancel pairings. An optional JSON
{"name":"My phone"} supplies a fallback token label; omission or blank uses
Suchi mobile, and names are limited to 64 characters. Each new pairing
replaces that user’s previous unused code.
201 {"pairing_url":"suchi://pair?…","qr_data_url":"data:image/png;base64,…","expires_at":1793495100,"name":"My phone","code":"<64 lowercase hex chars>"}
The link contract is
suchi://pair?v=1&server=<URL-encoded origin>&code=<code>. Its server is the
configured PUBLIC_URL origin, never a request or forwarded host.
The mobile app accepts HTTPS, or HTTP only for localhost, loopback literal
IPs, RFC1918 IPv4, and unique-local IPv6. Pairing creation returns 503
pairing_unavailable for other HTTP hosts (including .local/private DNS names
and link-local IPs), invalid origins, or ports outside 1–65535. Configure HTTPS
or a supported literal address; a refused origin does not replace an existing
pending code.
The 320-pixel PNG is generated locally, without a QR service. The code has 256
random bits; storage contains only its SHA-256 digest, owner, system, name, and expiry.
Treat the code, link, and QR as temporary credentials: anyone holding one can
pair until it expires, is replaced, is cancelled, or is used.
Pairing endpoints use Cache-Control: no-store, create no browser session,
and never log the code, link, QR, or token. Creation and exchange share the
login rate limit, including trailing-slash and percent-encoded slash aliases.
DELETE /api/mobile/pairing
Session-only cancellation accepts JSON {"code":"<code>"} and returns 204
even if that user’s matching code is already gone. It deletes only the supplied
code, so closing an old dialog cannot cancel a newer pairing. Invalid code
syntax returns 400 pairing_invalid.
POST /api/mobile/pairing/exchange
Public exchange accepts JSON
{"code":"<64 lowercase hex chars>","device_name":"Ritesh’s iPhone"}.
device_name is optional: a nonblank name overrides the browser’s label,
while omission or blank retains it. The server trims surrounding whitespace
and rejects names over 64 Unicode code points or containing control characters
with 400 bad_name before consuming the code. Correcting the name can
therefore reuse the pending code. The name is stored on the paired API token
and returned in both the exchange response and connected-app list. The app
must first validate the scanned link against the pairing origin rules,
confirm the server with the user, and run the compatibility handshake
without credentials or redirects. Production origins require HTTPS; supported
HTTP origins are for explicit local-development testing.
200 {"token":"<API token>","name":"My phone","scopes":"documents:read,documents:write"}
Scopes are a comma-separated string, matching the existing token-management
response. Code consumption, active-user/system-entry checks and bound token issuance
share one writer transaction; concurrent scans cannot issue two tokens. Invalid,
expired, replaced, used, disabled-account and no-longer-enterable-system codes return
400 pairing_invalid. An unavailable issuer is checked before consumption and
failed issuance rolls back consumption, so an unexpired code can be retried. If
successful issuance commits but the response is lost, create a new pairing: consumed
codes are not replayed. Successful issuance stores source: "mobile_pairing" in
that transaction; generating or failing to exchange a code does not register an app.
GET /api/tokens/
Session-only GET /api/tokens/?system=S01 returns active credentials in the selected
system with id, user_id, name, scopes, created_at, optional last_used_at,
optional source, and system_code after introduction. Paired credentials have
source: "mobile_pairing"; manual and older credentials omit it. Members see their
own credentials; administrators see all accounts only within that system. Omission
uses the original system. The list uses Cache-Control: no-store and contains no
token or token hash. DELETE /api/tokens/{id} revokes a device; logout-revoked tokens
are also excluded.
PATCH /api/users/me
Update the caller’s profile. Body accepts display_name only in the
current pass:
display_name— trimmed, 1–120 chars.email— refused with400 {"code": "email_change_unsupported"}. Email is the local-auth identifier; changing it needs a password challenge + a dedicated flow (not yet wired).- Empty body →
400 {"code": "no_fields"}.
user.display_name_changed, old→new) and return the fresh
UserSelf shape.
POST /api/users/me/avatar
Multipart upload with field name avatar. Server-side hardening:
- Content-Type sniffed on the first 512 bytes; only
image/pngandimage/jpegaccepted (a.pngfilename over plain text is refused). - Size cap 2 MiB; dimensions cap 2048×2048 (checked via
image.DecodeConfigbefore allocating the decoded frame). - The image is decoded and re-encoded as PNG. EXIF and any post-image polyglot bytes never survive; the on-disk representation is normalized. The original upload bytes are not stored.
- Blob lands in the CAS;
users.avatar_shagets the fresh SHA-256.
UserSelf on success. Emits
user.avatar_changed audit event with old + new sha.
GET /api/users/{id}/avatar
Serves any user’s avatar as image/png with a strong ETag from
users.avatar_sha and Cache-Control: private, no-cache; clients must revalidate
cached pixels. Profile responses append ?v=<avatar_sha>
to avatar_url so changed pixels also refresh image elements and bypass older
cached URLs. If-None-Match returns 304 for the current avatar or the new bytes
after an upload changes it. Returns 404 if unset.
PATCH /api/admin/users/{id}
Administrator session only. Sparse updates accept display_name, role
(admin or member), disabled, and capabilities; success returns the updated
user.
Admin creation (POST /api/admin/users) and updates normalize stored
capabilities to [] when the resulting role is admin; supplied slugs are
still validated. Demotion to member starts with no capabilities unless the
request explicitly supplies them, so legacy hidden grants cannot reappear.
Revocation hooks compare effective access: promotion preserves resources the
administrator can still use, while demotion revokes access not explicitly retained.
- Disabling the caller’s own account returns 403
cannot_disable_self, even when another active administrator exists. - A role or active-status update that would leave no active administrator returns
409
last_active_admin. Disabled administrators do not count.
Public-demo surface: /api/demo/*
Session creation and upgrade require SUCHI_DEMO_MODE=1
(see demo-instance); otherwise they return
404 {"code":"demo_disabled"}. The public mode probe returns
200 {"enabled":false} on a normal deployment.
modeis unauthenticated — the SPA calls it on boot to decide whether to render the demo banner. Cache-safe.sessionis unauthenticated — mints a signed anonymous token scoped read-only. No DB write; the token verifies statelessly via HMAC-SHA256 against a per-instance secret at$DATA_DIR/.demo-anon-key. TTL defaults to 15 minutes. Browsers use the issued HttpOnly cookie; non-browser read clients can send the token inX-Suchi-Demo-Token.session/upgraderequires a valid anonymous token. Provisions a scratch uservisitor-<hex>@demo.localand replaces the anonymous cookie with a digest-backed browser session. Its lifetime is the configured scratch TTL, not the normal 30-day login lifetime. It retains the constraineddemo-scratchidentity for both API calls and direct preview/download URLs. Upgrade clients must retain response cookies; no API token is returned.
403 {"code":"demo_upgrade_required"} except the upgrade endpoint itself.
Clients that follow the redirect-to-upgrade convention (auto-retry on
that code) get transparent write access without shipping login UI.
Document capture and lifecycle
POST /api/documents/
Multipart upload. Streams the file into the CAS by SHA-256, then commits the
document row and first durable job together. An unreferenced CAS object left by
a failed database commit is harmless and is reclaimed by suchi gc. MIME is
sniffed server-side; the multipart Content-Type header is a hint only.
An unreadable or non-rewindable upload is rejected with
500 upload_read_failed before any CAS write.
The target system is captured before upload
or deduplication. Equal bytes uploaded by one owner to different systems create
distinct document rows but may share a CAS hash. Split and email-attachment
children inherit their parent’s system; version uploads
have their own inheritance rules. Pre-upgrade stored receipts may omit address
fields; numeric lookup returns the current address.
Multipart fields:
Device OCR is accepted only when the sniffed upload is a PDF. Fresh rows always
record submitted confidence, language, and receipt time. At or above
DEVICE_OCR_MIN_CONFIDENCE, text is provisionally indexed with
content_source=device_ocr; below it, submitted text is discarded while its
non-content provenance remains. Native PDF text is authoritative and changes
the active source to server. For image-only PDFs with accepted nonblank device
text, post-ingest skips duplicate server OCR. Empty or skipped server output
never erases accepted device text. Dedupe and restore only record the ordinary
acquisition source and do not attach newly submitted OCR provenance to the
existing row.
An optional Idempotency-Key must be one canonical lowercase UUID v4. Invalid
keys return 400 bad_idempotency_key. Keys are scoped to the authenticated user
and shared across document and version uploads. Suchi fingerprints the
operation, predecessor, uploaded SHA-256, sanitized filename basename,
source_mtime presence/value, device source, a SHA-256 of device text,
normalized numeric confidence and OCR language using length-prefixed
fields, then qualifies the digest with the numeric target system. Upgrade prefixes
existing fingerprints with 1: without reconstructing their old inputs, preserving
same-system response-loss retries. Matching retries replay the original 200 or 201, preserve the
document Location, and add "idempotent_replay":true without adding a source
or job. Reusing a key with different normalized input returns
409 idempotency_conflict, including reuse across systems. Scope, current
membership/token authority and object authorization run on every request,
including a replay. Full response records are retained for 30 days and pruned
by later keyed uploads. After that window, ordinary content deduplication still
prevents a document duplicate; version retries
can recover their result from the version row.
201 Created { "id": ..., "sha256": "...", "size": ..., "mime_type": ..., "title": "..." }
Dedup state machine (always within the captured system and document owner):
-
Hash matches an existing live doc → records the upload as another
source and returns
200 OK. The document row and CAS blob are reused; extraction and classification do not run again:Repeating the same uploader and filename does not add another source row. A different uploader, filename, mailbox, watched path, or import source does. -
Hash matches a trashed document still inside its 30-day recovery window
→ clears
trashed_at, returns200 OKwith"restored": true. An expired match is not restored; the upload creates a fresh row while retention cleanup removes the expired one.
GET /api/documents/
Paginated ACL-scoped document list. Every SPA list view + mobile
client walks the archive through this. Scope: documents:read.
Query params (all optional):
Scope filters are additive. Malformed or oversized ID lists return a field-specific
400; an invalid language token returns 400 bad_lang.
Members see owned documents and explicit grants (direct or via group).
Administrators bypass document ACLs within the selected system; the
filing-system request context still applies.
Row shape (slimmer than GET /{id}; no content field — request
detail for that):
thumb_sha non-empty means the row has a thumbnail at
/api/documents/{id}/thumb (client can render immediately without
a HEAD probe).
split_origin_id is the immutable id of the upload that produced a QR-split
child; split_index is its one-based part number. Unlike split_parent_id,
the origin survives later hard deletion of the retired parent. Poll the accepted
upload until its active job is gone, then request
?split_origin_id=<accepted-id>. A trashed accepted row normally denotes a
split parent; a 404 means it was later hard-deleted. Report a multi-document
success only after every returned live child has completed post-ingest.
GET /api/documents/{id}
Full projection of one document: title, extracted content (post-OCR),
MIME, blobs, JD category, tags (slug list), correspondents. Trashed
docs are visible with trashed_at set so mobile clients can render
the undelete flow. owner_id identifies the document owner; trashed documents
also include deletes_at, exactly 30 days after trashed_at. Live and restored
documents omit both deletion timestamps.
encryption_state is encrypted when a password is still needed, or
decrypted after a successful manual or automatic password unlock. It is
omitted when no password unlock is recorded. The original blob is preserved.
Authenticated owners and administrators can preview and download trashed
documents until permanent deletion. Other ACL readers
cannot fetch trashed bytes, and public share links never serve trashed documents.
The normal sensitivity-reveal and raw-original download restrictions still apply.
200 OK
sources lists the distinct acquisition places that observed the document,
oldest first. It is separate from version history and document relationships:
a version supersedes, a link coexists, and a source only explains how the bytes
entered the archive. A mailbox source’s label follows the configured
mailbox’s current display name. Suchi retains the name captured at ingest and
uses it if that mailbox is later deleted; detail remains the observed address
and folder. added_at is when Suchi ingested the document;
created_at and optional source_mtime carry upstream dates.
content_source is device_ocr, server, or empty and names the authority for
the active content. Device provenance remains available after authoritative
server text replaces it. Pass include_content=0 for metadata-only reads; the
response keeps the stable content key with an empty string and does not carry
OCR text over the wire. This is the required mobile metadata/detail request.
jd_category_code / jd_category_name / jd_area_name are denormalized
from the JD tree so API clients can render a filing chip
without a second round-trip. See GET /api/jd/categories/
for the picker enumeration. jd_area_code is deliberately omitted —
the category’s own code encodes the area (e.g. 31 → area 30–39).
languages is a CSV of ISO-639-1 codes detected by the LLM classifier or
provided through metadata; it is empty when nothing was detected.
languages_locked is true once a user
has set the value via PATCH — future automatic detection won’t
overwrite it. Set languages (as CSV string or JSON array) via
PATCH /api/documents/{id} to correct the detection; passing an
empty value clears both the value and the lock.
Empty lists always serialize as [] (never null) so mobile clients
can iterate them safely.
GET /api/documents/{id}/thumb
Page-1 thumbnail rendered at ingest time from the archive PDF via
pdftoppm -f 1 -l 1 -r 40 -png. Omit width to preserve the stored PNG
bytes and immutable ETag "documents.thumb_sha". Optional integer
width=64..512 downscales with Catmull-Rom while preserving aspect ratio and
never upscales. Its ETag is "<thumb_sha>-w<actual-width>", where actual width
may be smaller than requested. Invalid, duplicate, or out-of-range width values
return 400 bad_width. Both forms use
Cache-Control: private, max-age=31536000, immutable.
Scope: documents:read. ACL rule matches GET /api/documents/{id}.
Returns 404 when:
- The doc has no archive PDF (image originals, EPUB,
.msg, etc.). - The instance ran the ingest without
pdftoppmon PATH; the pipeline logged the skip. - The doc was ingested before thumbnails shipped and hasn’t been re-rendered.
/preview/{id}. When the doc is
confidential or restricted, the endpoint returns 202 with a
small JSON body ({"sensitivity":"…","gated":true,"reveal_url":"?reveal=1"})
and Cache-Control: no-store unless the caller passes ?reveal=1. A validated
width is preserved in the gated URL, for example
?reveal=1&width=160. The SPA passes reveal=1 on the list view (the caller is
already authorized to view the doc) and blurs the returned bytes client-side;
a direct URL scan without reveal=1 cannot pull the raw first page. Mobile rows
request width=160.
GET /api/documents/{id}/versions/
Visible versions in the history containing {id}, ordered by creation time
then ID, oldest first. {id} can be any node: root, middle or head. A history
can branch. is_head=true means the node has no visible, live direct child;
multiple nodes can be heads, and a trashed node can still be marked as a head.
POST /api/documents/{id}/versions/
Upload a new version with previous_version_id={id}. Uses the
document upload multipart, device OCR, source timestamp and idempotency rules.
The new row keeps its predecessor’s system, owner, JD category, sensitivity and
explicit document ACL grants, plus its title if the uploader gives a bland
filename. A conflicting requested system fails.
A matching keyed retry replays before live-blob detection. After the 30-day
response cache expires, a keyed retry of the same bytes against the same
predecessor is still recovered from the resulting version row. Without a replay,
bytes already owned by any live document for the predecessor’s owner in that
same system return
409 {"code":"duplicate_version_blob","existing_id":<id>,...} instead of a
database error. The existing row may be the predecessor, another version in
the chain, or an unrelated document. existing_id is omitted unless the caller
can view that row. Replays also require current permission to view the result;
a trashed result returns 409 version_target_unavailable. Trashed rows do not
block a new version.
POST /api/documents/bulk_edit
Batch metadata mutation across N documents in one write transaction.
The SPA multi-select actions (bulk refile, bulk trash) call this
instead of looping N PATCHes.
Scope: documents:write. Cap: 500 documents per call.
Request
Response
- Every id is ACL-checked per the same rule as
PATCH /api/documents/{id}. Refused ids appear in the array withok: false, code: "forbidden"; the rest still run. - One
documents.bulk_editaudit event per call (withmethod+total+applied), not per doc — the granular outcome lives in the response array. - All authorized ids run inside a single
WriteTx. A per-id error inside the tx aborts the whole batch by design — partial writes would leave the archive in a half-state. restoreenforces the same 30-day window as individual restore. An expired authorized document rejects the batch with400 bad_parameters; already-live documents are unchanged.
{documents, method, parameters} body is Suchi’s stable bulk-edit
contract for the SPA and external clients.
GET /api/documents/pending-decryption
List live documents awaiting a password that the caller can view in the selected
system, including ACL grants. Requires documents:write for API tokens; appearing
in this list does not itself grant permission to decrypt.
200 OK
POST /api/documents/{id}/decrypt
Attempt to decrypt a live document in encryption_state='encrypted'.
Requires permission to change the document and documents:write for API tokens.
Body: {"password": "...", "remember": true, "label": "opt"}
password— required. The user password for the PDF.remember— optional. Whentrue, the password is AEAD-sealed with the server’s.decrypt-keyand stored indecryption_passwordsfor the doc’s owner and system. Future encrypted uploads by that owner in that system auto-try it.label— optional operator-visible tag (“BofA 2024”) attached to the stored password.
decrypted_blob, flips state to 'decrypted', re-enqueues post-ingest.
Returns 204 No Content.
Failure modes:
400 bad_password— qpdf rejected the password400 empty_password— body missing the field404 not_found— document missing, trashed, not encrypted, or unavailable to change503 decrypt_disabled— server started without.decrypt-key
POST /api/documents/decrypt-batch
Apply one password to documents in the selected system, checking change
permission per document. Requires documents:write for API tokens. Results
preserve request order and report failures per item.
Internal failures return db_read or decrypt_failed; details remain in server logs.
Body:
remember=true, a successful batch stores the password once for the
calling user in the selected system. It stores nothing if no document succeeds.
The label defaults to the first successfully decrypted document’s title.
DELETE /api/documents/{id}
Moves a live document to Trash by setting trashed_at. The document remains
recoverable for 30 days. 204 No Content on success, 404 if the document is
not live. Restoring and trashing it again starts a new 30-day window.
POST /api/documents/{id}/restore
Clears trashed_at during the 30-day recovery window. Restoring a live
document is idempotent and returns 200 OK with {"affected": 0}. An expired
trashed document returns 404 and remains eligible for automatic purge.
GET /api/trash/
Returns a paginated envelope of trashed documents in the selected system. Members
see their own documents; administrators see all owners there, never other systems.
Each row includes trashed_at and deletes_at, the Unix timestamp 30 days later.
DELETE /api/trash/{id}
Permanently deletes one already-trashed document. Requires documents:write
and delete permission for that document. Returns 204 No Content; live,
restored, and missing documents cannot be hard-deleted through this route.
DELETE /api/trash/
Permanently deletes every document in the selected system’s caller Trash scope
for which the caller has delete permission. Members are owner-scoped; administrators
can purge all owners only there. Returns 200 OK with {"purged": N}.
Permanent deletion removes document-owned metadata and workflow state, revokes
every share link containing the document, clears non-foreign-key references,
and replaces prior document audit history with a minimal document.purge
record. Rendered files are removed safely. Original and derived CAS blobs stay
on disk until offline garbage collection, which reclaims
bytes no document or avatar references while all archive writers are stopped.
Search and retrieval
GET /api/search/
Ranked search over document text plus filing metadata. Scope: documents:read.
All callers, including admins, stay within the selected/token-bound system;
ordinary document visibility applies before results, counts and snippets.
Metadata filters cannot expose foreign names or a document the caller cannot view.
Query params:
Ranking uses title-weighted BM25 plus an optional bounded recency term.
recency=off returns raw BM25 ordering. Formula, score direction, weights,
snippet behavior, and filter-only ordering are documented in
Search architecture: Ranked Search.
Response shape — {count, next, previous, results} DRF envelope.
Each hit carries {id, title, snippet, rank, created_at, mime_type, sensitivity}. snippet wraps a matching span in <mark>...</mark> for
non-sensitive documents. Confidential and Restricted hits still participate in
ranking but return an empty snippet; clients must render a sensitivity
placeholder rather than hidden OCR text. Malformed queries return
400 bad_query; logs contain only a SHA-256 correlation value, never the raw
query.
Query language
GET /api/search/ and GET /api/documents/ use the same bounded parser and
matching plan. See Query language for terms, phrases,
implicit AND, negation, filters, comparisons, escaping, unsupported syntax,
limits, completion, and bad_query errors.
GET /api/autocomplete/?q=... completes the active final query token or falls
back to bounded taxonomy-prefix suggestions. See
Search architecture: Query completion and autocomplete.
Saved-view query validation
POST /api/saved_views/ and PATCH /api/saved_views/{id} accept
filter_json as a JSON-encoded object string up to 2 KiB. Supported keys are
q,
tags__id__in, correspondents__id__in, document_type__id,
jd_category_id, sensitivity, ordering, and document_ids.
Each user’s name uniqueness and 50-view limit apply within the selected system.
Sharing a View does not share system membership or bypass document ACLs.
document_ids must be a JSON array containing 1–100 positive integers. The
server removes duplicates while preserving first occurrence. It is the stable
contract for an exact snapshot View; opening or sharing the View still reapplies
document authorization. Other supported filters are additive, so an exact
snapshot stores document_ids alone. The web app does this instead of deriving
a live q from question text.
Saved-view tag and correspondent lists accept at most 100 unique positive IDs.
The server removes duplicates in first-seen order during normalization.
When q is present, the server normalizes its spelling and resolves referenced
categories, tags, correspondents, and document types before writing the row.
Syntax errors, unknown values, and ambiguous values return 400 invalid_filter;
an update that fails validation leaves the stored view unchanged. Existing
flat filter objects remain readable, while newly created web-app views use q
as their search constraint. See Query language: Saved Views.
GET /api/documents/{id}/similar
Returns authorized lexical neighbours from the source document’s filing system
using the shared FTS5 index. Source access is checked first and the source is
excluded from candidates. ?limit=N accepts 1–50 and defaults to 10. Token
selection, weighting, score floor, title-only fallback and the absence of recency are in
Search architecture: Similar documents.
score is higher for stronger similarity. matched_on_title_only warns that
the source had no extracted body text. method is currently fts.
GET /api/languages/
Language facet — distinct codes across the caller’s visible live documents in the
selected system, with per-code counts. Powers the SPA’s search-page language filter
and doc-detail chip picker. Ordered by count descending, then code ascending.
200 OK
GET /api/search/?lang=de. The filter uses a
comma-bracketed LIKE against documents.languages so 2-letter
codes don’t false-match 3-letter neighbours (de vs deu).
Filing and metadata
GET /api/jd/categories/
Read-only listing of every category in the selected system’s JD tree, with
area_code + area_name denormalized onto each row so a picker can render a grouped
tree in one request. Category system_code identifies that filing system; system
remains the protected-row boolean. Used by mobile pickers, the SPA’s file-under
dropdown and external clients resolving jd_category_id.
Query params (all optional, in addition to the shared system context):
?q=<prefix>— case-insensitive prefix match on the category code OR name.?q=22and?q=taxboth match22 Tax.?area=<code>— scope to one area’s 10-slot range.?area=20returns categories 20–29.
GET /api/tags/
Read tags in the selected system with optional parent-filter. Query params:
Response is the standard pagination envelope:
next/previous are omitted (or empty string) when there’s no
neighbor page. count is the total matching the current filter,
not the length of results. Each TagView carries parent_id
(nullable) and child_count so the UI renders the shallow tree
without extra round-trips.
POST /api/tags/
Admin-only. Body: {"name": "...", "slug": "...", "color": "#..."} —
only name is required. Slug defaults to slug.Make(name);
color defaults to #a6cee3 at the DB level. Returns 201 {"id": <int>}
on success, 409 conflict when the name or slug already exists in that system.
PATCH /api/tags/{id}
Admin-only. Body: any subset of {name, slug, color}. Empty body
returns 400 no_fields. Same 409 conflict on unique collision.
Parent moves live on PATCH /api/tags/{id}/parent (below) — that
endpoint owns the cycle-check invariant and is not duplicated here.
DELETE /api/tags/{id}
Admin-only. Returns 204 No Content on success, 404 not_found
otherwise. document_tags rows cascade via ON DELETE CASCADE;
children of the deleted tag re-root via ON DELETE SET NULL (the
nested-tag migration set that up so a deleted parent doesn’t
orphan its subtree).
PATCH /api/tags/{id}/parent
Admin-only. Body: {"parent_id": <int-or-null>}. The parent must belong to the tag’s
system. Rejects self-parent (400 self_parent) and cycles (400 cycle). Tag
ancestry does not imply document-search inheritance; see Architecture.
GET /api/documents/{id}/correspondents/
List the correspondents attached to a document, grouped by role.
Bank statement = {sender: HDFC, recipient: account holder};
invoice = {sender: vendor, recipient: customer}. Roles are
sender, recipient, cc, other.
Response:
POST /api/documents/{id}/correspondents/
Add a correspondent. Body: {"name": "...", "role": "sender|recipient|cc|other"}.
Upserts the correspondent by name within the document’s system. role=sender also mirrors to
documents.correspondent_id when no primary is set — the single-
correspondent code paths (list column, existing automations) still find
the sender.
DELETE /api/documents/{id}/correspondents/{cid}/{role}
Remove one junction row. 204 on success.
PUT /api/documents/{id}/custom_fields/{field}
Write a typed custom-field value. {field} accepts the numeric field ID or name,
resolved within the source document’s system. Values can be written and deleted;
document detail and /api/custom_fields/ do not return them, and there is no
dedicated value read endpoint. /api/custom_fields/ lists field definitions.
Ordinary metadata values cannot reference another system, even when the actor
enters both. An authenticated documentlink write may cross systems only when
the actor can change the source and view the live target, including entry to both,
checked in the source write transaction. Actorless handler/automation writes
accept only same-system live targets. Future value reads must recheck target
visibility and redact inaccessible links; that read capability is not implemented.
Body: {"value": <T>} where <T> matches the field’s data_type:
url validates as http/https. Empty value ⇒ row is deleted.
Returns 204 No Content on success, 400 with a code on validation
failure, and 404 if the document is trashed, inaccessible, or missing, or the
field is missing. A successful write enqueues a storage-path
re-render if the field is referenced in the template.
DELETE /api/documents/{id}/custom_fields/{field}
Remove the value row for one live, change-authorized document and field pair.
204 on success; trashed documents return 404 without changing values or jobs.
GET /api/presets/
Admin-only preset catalog for the setup wizard. Returns one row per
built-in preset defined in core/jd/presets.go; the wizard renders
the area list as a tree preview so an operator can see what a preset
actually contains before applying. No envelope — the population is
bounded (five presets today).
200 OK
blank: true marks the empty preset (no areas), which the wizard
gates behind an explicit confirm checkbox. Categories aren’t included
per row — they’d bloat the payload, and the wizard doesn’t render them
in the picker step. Once a preset is applied via
POST /api/admin/setup/preset, the tree becomes visible through
/api/jd/categories/.
POST /api/admin/taxonomy/import
Session-admin-only, with existing same-origin protections; API tokens cannot
administer taxonomy. Accepts strict suchi-taxonomy/v1 HuML or TOML and previews
or atomically applies it. Taxonomy content is bounded to 1 MiB; invalid input is
rejected before mutation, including when skip_seeds is true.
Request body
format is "huml" or "toml"; omitted/empty means HuML, never content
sniffing. UI callers send it explicitly. apply=false returns the preview.
Apply sends the same content, format, seed choice and remaps plus
"expected_state_hash":"<state_hash from preview>" and "apply":true.
Optional body target_system must agree with a file’s top-level system.
A prefixed file selects its code ahead of ordinary ?system= query context;
that query is only the fallback for unprefixed input. On first prefixed import,
omitted existing_system_code adopts the original archive under the file code.
Supplying a distinct code (for example "existing_system_code":"A00") preserves
the original separately. This choice is unavailable after introduction and is
bound into preview/apply; new systems initially admit only instance admins.
Send remaps during preview as well as apply. Keys are incoming user category
codes, values 0 to skip or a free category code in the same permitted decade.
Generated structure is neither incoming nor remappable. An unresolved collision
prevents apply; changed input/options/relevant archive state returns
409 stale_preview, requiring a new preview without discarding the user’s input.
The hash is a consistency check, not a permission token or signed session.
200 OK (preview and successful apply share the same fields)
The UI explains
00-09 System index separately; it is not in either area array.
Only safe initial setup can replace bootstrap structure: filed documents,
including recoverable Trash, and dependent configuration prevent replacement.
Merge preserves same-code/same-name local descriptions and existing rule names,
disabled originals and user-owned forks. A skipped category skips the entire
dependent starter. Destination introduction/creation, membership preservation,
symbol resolution, supported additions, provenance and durable jobs commit together.
Apply rechecks that the authenticated actor remains an active instance admin.
Validation errors retain readable messages and may include structured field
issues. Unknown formats/keys, reserved author declarations, malformed parameters
and unsupported behavior are errors, never partially accepted extensions.
GET /api/admin/taxonomy/export
Session-admin-only. Build and validate a consistent current snapshot in HuML
or TOML: ?format=huml|toml&system=S01 (default HuML and original system).
Add skip_seeds=true for tree-only output, omitting keywords/starters.
Attachment filenames are <SYS>.taxonomy.huml|toml after introduction and
archive.huml|toml before it; the current system code/name accompanies the snapshot.
Snapshot header is id = "archive", version = 1, separate from last-import
provenance. Generated System/49 is implicit. Default export preserves only
representable preset-owned starters; disabled/unrepresentable rules fail rather
than being enabled or silently lost. Legacy incompatible reserved structure also
fails without automatic repair. User-owned rules/forks, access/review state and
document/version/link identity require complete backup or their portable owner.
Activity and access
GET /api/stats/
Dashboard counters in one round-trip. Replaces four page_size=1 list
probes plus two task lists — same numbers, one query. Every count is
visibility-scoped per principal via the same WHERE fragment the list
endpoints use.
Scope: documents:read.
200 OK
inbox_category_id echoes the selected system’s JD Inbox pointer; keep the system
code in the browser’s #/documents?jd=49&system=S01 route after introduction.
pending_intelligence is its visible extracted-fact review count for callers with
archive_intelligence. dead_jobs is system-scoped and admin-only (members see 0);
pending_approvals is scoped to the member’s assignee or all open work in that
system for admins. ingested_7d counts live docs created in the last 7 days —
useful for a sparkline; not a filing-date metric.
GET /api/events/
Cursor-based activity feed over the audit log for integrations
that need a near-term change stream without polling /api/documents/ and
/api/tasks/ on a timer.
Scope: events:read. Browser sessions have it implicitly; API tokens need
the exact scope.
Query params (all optional):
?since_id=<id>— return rows withid > since_id. First call omits it and receives the newest visible tail in chronological order; every subsequent call passes the previous response’slatest_id.audit_events.idis a strictly-increasing INTEGER PK.?kinds=a,b,c— comma-separated allow-list. Unknown kinds silently return nothing (no error), so a client rolling out a new kind name never breaks on an older server.?limit=<n>— 1–200, default 100. System and visibility predicates apply before cursor selection and pagination; a foreign event cannot consume the page or advancelatest_id. Keep a separate cursor for each captured system.
- Every returned row belongs to the selected system, including for administrators. Document rows also require current document visibility.
- Approval-task rows require system entry and any attached document’s visibility,
plus the exact
user:<id>orrole:<role>assignee unless the caller is an admin. - Other non-document rows are visible only to their producing user/token or an
administrator within that system. Truly global events such as
backup.writtenare not part of this filing-system feed; use operator logs or an audit sink. - Server-lifecycle / janitor kinds (
server.start,audit.pruned,jobs.reclaimed) are hidden from the feed for every caller, admin included. They still land inaudit_eventsfor the durable trail; read the table directly (or a SIEM sink) if you need them.
summary is a pre-rendered one-liner
(“Ingested March rent”) so clients don’t need a title lookup per row.
The privacy invariant applies — summaries carry titles and kinds
only, never document content or OCR text.
Kinds currently emitted (non-exhaustive; new emit points land in
new server versions and appear here as they arrive):
200 OK
latest_id and pass it back as
since_id on the next call. The server intentionally stores no per-consumer
cursor.
Feed horizon. Audit rows are pruned on the backup ticker per
AUDIT_RETENTION_DAYS (default 20, cap 100). since_id stays
valid across purges — IDs only grow — but a client that comes back
after the retention window has elapsed sees a clean empty diff, not
“the history it missed.” This feed is a near-term change stream, not
a permanent record. Long-horizon consumers should read audit_events
via a SIEM sink instead.
GET /api/tasks/
Read durable-outbox work in the selected system: what is running, failed or queued.
Documentless jobs still require that system ownership; global operational work is
not this collection. Document work additionally requires document visibility.
Query parameters:
Malformed, non-positive, duplicated, or unsupported filter values return
400 bad_task_filter; they never silently broaden the result. Job counts use
the same state, doc_id, and kind filters as results. With
include=approvals, job counts are zero and only approvals_open is populated.
Response shape:
approval_tasks are scoped to the caller: an entry appears only if the
task’s assignee resolves to the current user. Terminal statuses
(resolved, expired) are omitted.
Document-bound tasks are also omitted while their document is trashed and
can reappear if it is restored while the task is still open or claimed and its
run is still active. A task that expires or whose run advances while the
document is trashed does not reappear. Documentless tasks retain their run’s system
and need entry there; document tasks also require document visibility. Assignment
alone cannot admit a nonmember. Resolve actionable work via
POST /api/approvals/tasks/{task_id}/resolve. approvals_open in
counts is the true open-inbox size, unaffected by limit. Document-bound
tasks include the current title and filing category plus a thumbnail-presence
flag so clients can identify the document without fetching its extracted text.
API tokens need documents:read to list tasks and documents:write to resolve
an approval; browser sessions continue to use the signed-in user’s access.
Administrator sessions can resolve dead outbox rows directly:
Both return
404 not_found unless the referenced row is dead and available within
the request’s system boundary. API tokens cannot retry or dismiss jobs. Neither
response returns the stored provider error.
Document suggestions from archive matching and the optional LLM classifier
also appear in approval_tasks. Their approval_name is document-change;
the vars object carries the suggested field, value, confidence, source, and
supporting document IDs. Suggestions are field-specific: a document can be
filed under a category while a lower-confidence tag, correspondent, title, or
document-type suggestion still waits for review.
Automations surface: /api/automations/*
Automations combine triggers, conditions and actions on events within their owning
system. Names, metadata references and rule forks are scoped there. These routes
require a browser/OIDC session. Full guide at
Automations.
PATCH is sparse — send only the fields you want to change. The SPA’s
Enable/Disable button posts {"enabled": false}; a full-edit form posts
name + order + enabled + triggers + actions. When triggers or
actions is present, its value replaces the child rows wholesale (an
empty array clears them); leave the key out to keep them as-is.
Approvals surface: /api/approvals/*
State-machine engine for human-in-the-loop review chains. Definitions and runs,
including documentless runs, belong to one system; slugs resolve there and all
six endpoints retain the explicit/token system boundary:
See the approvals guide for the spec shape, error
codes, timeout behavior, and a worked invoice-approval example.
Permissions surface: /api/groups/* + /api/acls/*
Named user collections plus per-object grants. These routes require a
browser/OIDC session. Owners and administrators can inspect or change grants;
receiving a grant does not confer delegation rights. Full guide at
permissions.
{kind} is one of document|tag|correspondent|document_type|storage_path.
Grant writes accept only perm_bits=1 (View), 3 (Edit: view + change),
or 7 (Full control: view + change + delete). Other combinations return
400 bad_permissions.
POST /api/share_links/ — multi-doc bundles
A share link covers 1–200 documents under a single opaque token.
Bundles work by design: pass an array on POST, and the recipient sees
one landing page with every doc listed. API tokens need documents:write.
Request
doc_ids: 1–200 live documents from one enterable system. Members must own them; administrators bypass ownership, not an explicit request or token system ceiling.label: optional human string shown on the landing page, up to 200 bytes.password: optional, up to 1024 bytes. It is Argon2id-hashed at rest.expires_in_sec: seconds from creation, from0(no expiry) through one year.
token, the absolute public_url resolved from
PUBLIC_URL, and the shared_by + instance_host identity recipients see.
Public paths (unauthenticated, rate-limited):
The bearer is deliberate public access, not permanent authority. Every request
rechecks that its creator remains active and can enter the share’s system; only
still-shareable live documents from that system are exposed. Removing membership
revokes that member’s shares, and re-admission does not reactivate them.
GET /s/{token}— content-negotiated on theAcceptheader. A browser (Accept: text/html) gets a small landing page with the bundle label,Shared by <display name> · <instance host>, a password form when required, and one download row per doc. The identity appears before the password form. If the display name is empty, the page shows onlyShared from <instance host>. Programmatic callers that acceptapplication/jsonget{label, shared_by, instance_host, requires_password, docs:[{id, title, mime_type, original_size, download}]}.POST /s/{token}— password-form submission from the HTML landing. Verifies the password server-side and sets a one-hour HttpOnly, SameSite=Strict unlock cookie scoped toPath=/s/{token}. The cookie contains a signed proof, not the entered password.GET /s/{token}/{doc_id}/download— streaming download of one doc from the bundle. API clients can send the password inX-Suchi-Share-Password; browsers use the unlock cookie above. Passwords are not accepted in URLs, where they can leak into history and proxy logs.
GET /api/share_links/
(documents:read for API tokens). Revoke via DELETE /api/share_links/{id}
(documents:write) — the share token becomes 404 immediately (no soft delete).
Initial setup
POST /setup
First-boot admin creation. Consumes the one-time setup token minted
at boot when no users exist. 409 Conflict if already initialized.
Request:
GET /api/admin/setup/state
Admin-only. Onboarding timestamps and intent are archive-wide; intent is stored
locally and only recommends a filing tree. current_preset and
filing_tree_chosen describe the selected ?system=Snn, not the archive’s
default system. The preset identifies the last imported choice, not proof that
the current merged tree equals that file. recommended_preset derives from
the archive-wide intent. Absent timestamps and empty strings are omitted.
POST /api/admin/setup/complete
Admin-only. Requires an explicitly chosen filing tree in the selected
?system=Snn; an untouched Inbox-only bootstrap returns 409 filing_tree_required.
Explicitly choosing Blank counts as a choice. Success stamps the archive-wide
onboarding completion timestamp and returns 204.
POST /api/admin/setup/intent
Admin-only. Body: {"intent":"personal|household|freelance|small_business|custom"}.
The response returns the selected intent and mapped preset (solo, household,
freelance, smb_billing, or blank for custom). It neither applies a tree
nor hides setup capabilities.
Archive research
Chat endpoints require authentication,documents:read, and archive_chat
(implicit for administrators). Public demo sessions are denied.
GET /api/chat/status
Returns live runtime availability and a safe provider label:
POST /api/chat
context_source_ids contains at most three positive cited
IDs. Scope accepts a rich query, up to 100 exact IDs, and additive category,
sensitivity, document type, tag IDs, correspondent IDs, inclusive created-time
bounds, and language fields. Every source and context ID is reauthorized with
fresh ACL, trash, sensitivity, and scope checks.
The response contains at most six sources, structurally validated citations,
grounding state, and complete extracted-fact counts. Suchi adds missing inline
markers from the provider’s structured citation list. Provider evidence is
bounded to 300 Unicode characters per title and the active 1,600–4,800
Unicode-character Research context preset
per snippet, plus three approved facts per source and twelve facts total:
502 invalid_provider_response; provider failures return
502 provider_failure; a disabled runtime returns
503 model_unavailable; the per-user rate or retrieval/provider concurrency
gates return
429 chat_rate_limited with Retry-After.
Q&A is read-only. Saving the retrieved documents as a View or opening
Approvals or Calendar is a separate browser workflow.
Extracted facts (archive_intelligence)
These endpoints require archive_intelligence, implicit for administrators.
Reads also require documents:read; extraction and resolution require
documents:write plus Change permission on each source document. Public demo
sessions may read the schema and accepted demo-corpus date facts only, with
the usual token read scope and source-document visibility checks. They cannot
read pending/rejected facts, run extraction, or resolve facts. This exception
does not grant archive_intelligence to demo users or other ordinary users.
GET /api/intelligence/schema
Returns registered extracted-fact types and role vocabularies.
GET /api/intelligence/
Returns the standard pagination envelope. Filters:
accepted is the storage status for a date available to Calendar. It may be
set automatically by the configured model threshold or by a person’s decision
in Approvals; reviewed_by is present only for the latter.
Stats responses expose the pending extracted-fact count as
pending_intelligence. Browser document-list filters use
jd_category_id, document_type__id, tags__id__in,
correspondents__id__in, created_at__gte, and created_at__lte.
view_id cannot be combined with q or document_ids. The server resolves
the View rather than trusting client-reconstructed filters, including legacy
tag, correspondent, type, category, and sensitivity fields.
A nonempty document_ids value must contain at least one valid ID; an empty
CSV list returns 400 instead of widening the scope to all documents.
Rows contain generic type, role, value, sort_value, evidence,
confidence, review state, extractor/version, and source-document presentation
fields.
For type=date, value contains date and precision. Exactly three
precisions are supported:
sort_value equals this normalized date. First-day values for month/year
precision are sorting placeholders, not exact dates. sort_from and sort_to
compare those values; a January range includes a year-only fact, but an August
range does not. Omitting those bounds retrieves all periods in the selected
document scope. Confidence is a separate model-reported score, not a measured
probability. See Document dates
for display and review behavior.
The optional precision filter applies in SQL before the total count and
pagination, together with the document scope and ACLs. Unsupported values or a
precision filter without type=date return 400. Exact-day agendas use
precision=day with the same date in sort_from and sort_to, so month/year
placeholders are excluded even on the first day of a month or year. Omitting
precision preserves the existing all-precision behavior. Legacy rows without
stored precision are treated as day by this filter.
POST /api/intelligence/extract
202 with granular per-document results. It does not add another model
call to the pipeline. The current confidence threshold and date_auto_apply
setting decide whether each extracted date enters Calendar or stays pending.
POST /api/intelligence/resolve
decision is accepted or rejected. Only pending, authorized candidates
change. The bounded transaction returns granular not_found, forbidden, and
already_resolved outcomes.
LLM classifier settings
These endpoints are admin-only. The model remains optional; local archive matching and automations keep running when it is disabled.GET /api/admin/settings/llm
Returns masked persisted and runtime state. has_api_key is the only key
signal; plaintext key material is never returned.
research_context_mode is focused, balanced, or detailed. Missing or
invalid stored values read back as balanced.
POST /api/admin/settings/llm
Body: {enabled, endpoint_url, model, api_key, clear_api_key, egress_ack, confidence_threshold, date_auto_apply, archive_enabled, archive_auto_threshold, archive_review_threshold}. The model threshold must be
between 0.50 and 0.95. When date_auto_apply is true, extracted dates at or
above that threshold enter Calendar; other dates remain pending for Approvals.
Saving an enabled automatic-date configuration also promotes matching pending
dates without another model call. Turning it off affects new extraction and
does not remove existing Calendar dates. The archive review threshold must be
0.50 to 0.90; the archive auto-apply threshold must be 0.55 to 0.95 and
greater than review.
Enabling requires an HTTP(S) endpoint and model. Non-local endpoints require
egress_ack=true. A blank api_key preserves any stored key; a non-blank key
is AEAD-sealed before the settings transaction commits. clear_api_key=true
stores an encrypted empty value for web-managed configuration. A key supplied
by the config file or environment remains authoritative. It cannot be combined
with a non-empty key. Disabling preserves the saved endpoint and takes effect
when that endpoint is not pinned by the config file or environment.
The response reports whether the saved configuration is active. The durable
subscriber is present from boot, so first activation, configuration changes,
key rotation, disable, and re-enable all apply immediately.
PATCH /api/admin/settings/llm
Updates only the global Archive research source-text preset:
research_context_mode, with a value of
focused, balanced, or detailed. The response returns the saved value:
POST /api/admin/settings/llm/test
Accepts the same connection fields without saving them. A blank candidate key
uses the already-resolved stored or environment key. The server sends fixed
synthetic text and returns success only when the endpoint responds with a model
result that passes production validation.
Runtime setup settings
GET /api/admin/settings/preferences returns the effective backup interval and
OCR language list. POST to the same path with
{backup_interval_hours, ocr_languages} resets the backup scheduler and
replaces the language snapshot used by future OCR jobs immediately.
GET /api/admin/settings/ingest returns the watched directory, owner and target
system. POST with {fs_watch_dir,fs_watch_owner_email,fs_watch_system} validates
the active owner can enter the target, stops the old watcher, and starts the
replacement before success. Directory/owner are supplied together; omitted system
means original system 1. This is global producer configuration, not the browser’s
mutable current system. Boot-pinned fields retain configuration precedence.
Email accounts (multi-mailbox intake)
Multi-mailbox intake lives under/api/email-accounts and requires a
browser/OIDC session with the mailboxes capability. One
row per mailbox: immutable filing system, per-account owner, provider preset, derived auth mode
(password or xoauth2), sealed secret at rest, per-account folder,
poll cadence, and one provider-neutral intake policy. Edits
are picked up by the poller supervisor without a restart. The
sealed secret (sealed_secret) is never returned in responses.
Representative account row:
GET /api/email-accounts
Requires the mailboxes capability and system entry. Within the selected system,
admins see every configured row; members see only rows they own. Response:
ready=false means the build lacks a usable registration
or the deployment override is invalid.
POST /api/email-accounts
Requires the mailboxes capability. The request captures the selected system.
Admins may set owner_id to an active user able to enter it; members always create
rows owned by themselves. Body:
intake_policy.rules contains 1 to 20 rules. Each rule has a selection of
all, files, or matching and a content of email_and_files or
files_only. Rules are ORed: a message is accepted when any rule matches.
When more than one matching rule selects different content, email_and_files
wins.
A matching rule accepts from, recipients (To or Cc), case-insensitive
subject_terms, and case-insensitive attachment_names globs. Populated
criteria inside that rule are ANDed; comma- or newline-separated values inside
one criterion are ORed. A matching rule requires at least one criterion. all
and files rules do not accept matching criteria. The pre-v0.1 API accepts only
the rules shape; the former flat policy shape is not supported.
Returns 201 {"id": <int>} on success.
GET /api/email-accounts/{id}
Requires the mailboxes capability. Members can read only their own
rows. sealed_secret is omitted.
PATCH /api/email-accounts/{id}
Requires the mailboxes capability. Sparse update — send only the
fields you want to change. Members can update only their own rows, cannot
reassign ownership, and cannot set tls_ca_file. Provider and authentication method are immutable;
create a new mailbox to change providers. Other fields match POST. password is
sealed on write; sending an empty password leaves the stored
credential untouched.
DELETE /api/email-accounts/{id}
Requires the mailboxes capability. Members can delete only their own
rows. Returns 204 No Content.
POST /api/email-accounts/{id}/test
Requires the mailboxes capability. Members can test only their own
rows. Dials the row’s host, attempts auth using the stored credential
(password or refreshed OAuth token), then logs out.
Never 500s on a connect-refused — that’s user data being wrong,
not a server bug. The result clears or updates last_error while preserving
last_sync_at, because a connection test is not a mailbox poll.
POST /api/email-accounts/{id}/preview
Requires the mailboxes capability. Evaluates the supplied intake_policy
against at most 25 recent messages above the mailbox cursor and returns up to
five matching envelope samples. Suchi fetches IMAP envelope metadata and
BODYSTRUCTURE only; message bodies are not downloaded, stored, or logged.
POST /api/email-accounts/oauth/start
Requires the mailboxes capability. Kicks off a Microsoft device-code
flow. Published builds use Suchi’s shipped registration; a deployment may
override it through the config file or environment. The server returns
503 no_msal only when no usable registration is available. Body:
expires_at time, capped at 15
minutes. Suchi polls Microsoft in the background, independently of ordinary
HTTP request deadlines.
The handle binds the authenticated actor and starting ?system=CODE context.
Completion against another system fails, even for an administrator; browser
switching discards the pending flow/handoff.
POST /api/email-accounts/oauth/complete
Requires the mailboxes capability, the starting actor/system and current entry.
Members can complete only against their own account_id in that system. Body:
account_id is optional. When present, suchi seals the fresh MSAL
cache into that row’s sealed_secret, confirms its xoauth2 auth method,
and enables the mailbox. While Microsoft is still waiting for the user, the endpoint
returns 202 Accepted:
account_id is omitted, Suchi returns an authenticated server-sealed creation
handoff bound to {actor_id,system_id,credential}. Submit it to a fresh
POST /api/email-accounts in that same actor/system context. Create validates the
binding and current membership, then stores the ordinary at-rest Microsoft
credential. Copying another mailbox’s at-rest ciphertext is not accepted:
POST /api/email-accounts/{id}/oauth/revoke
Requires the mailboxes capability. Members can revoke only their own
rows. Clears the stored sealed_secret, sets enabled=false, and
keeps auth_method=xoauth2. The poller stops touching the row immediately;
sign in with Microsoft again to replace the credential and re-enable it. Also revoke Microsoft-side at
mysignins.microsoft.com
to invalidate the refresh token upstream.
Errors
Standard shape when a handler returns JSON:code is programmatically stable across releases; error is not.
Responses with status 500 or 502 use generic messages. Provider, database,
filesystem, and cryptographic details stay in the server log with the request
ID instead of crossing the API boundary.