last_edited: 2026-10-05 title: HTTP API description: The agent-first HTTP API: filesystem-shaped endpoints, revision preconditions, and the daemon's error contract.
HTTP API¶
The HTTP API lets clients browse, retrieve, file, and reorganize documents.
docbank daemon run serves it, and every CLI data command uses it. CLI commands
cannot open the vault directly.
This page owns the wire contract: routes, authentication, preconditions, content verification, and errors. Use Docbank for agents for a task-oriented starting point.
| Reader question | Contract |
|---|---|
| Which operation should I call? | Endpoint map |
| How do I process and search a document version? | Document processing |
| How do I reject a stale write? | Revisions and If-Match |
| When may I trust downloaded bytes? | Content verification |
| Which credentials does a request need? | Authentication |
| How do I handle a failed request? | Error mapping |
Mailbox imports and explicit EML transfers¶
Mailbox routes live under /api/v1/mailbox. Owner identity comes from the
authenticated vault principal, never from a supplied owner field.
| Route | Contract |
|---|---|
POST /containers |
Declare stable id, sha256, size, and format (mbox or zip). JSON is limited to 1 MiB. |
PUT /containers/{id}/chunks/{index} |
Stream one ordered 64 MiB chunk (short final chunk allowed), declaring X-Docbank-Blob-Hash and X-Docbank-Blob-Size. The response repeats the verified identity. |
GET /containers/{id} · POST /containers/{id}/seal · DELETE /containers/{id} |
Inspect, verify the complete source and seal it, or abort an incomplete upload. Sealed sources cannot be aborted. |
POST /containers/{id}/preview |
Supply dialect. Inspect up to three messages, reading at most 16 MiB of expanded message data, and 100 entry descriptors, with total entry_count and has_more. |
POST /jobs |
Supply id, container_id, container_sha256 and settings.destination_id. Dialect defaults to mboxrd; recipe and label-to-tag mappings are optional. The response includes normalized settings and the parser/MIME recipe identity. |
GET /jobs · GET /jobs/{id} |
Inspect durable work. Lists return newest first and use after job ID and limit (maximum 100). |
GET /jobs/{id}/occurrences |
Read up to 100 receipts after the after ordinal. |
GET /jobs/{id}/events |
NDJSON progress, result or error events. Disconnecting does not cancel the job. |
POST /jobs/{id}/cancel · POST /jobs/{id}/resume |
Cancel, or supply the exact prior request and boolean continuation to resume/continue. |
POST /archives |
Register id and description for an application-independent source archive. |
POST /transfers |
Stream EML bytes. X-Docbank-Transfer is base64url-encoded JSON containing archive ID, stable reference, hash, size, settings, destination ID, name and optional expected revision. |
Container manifests hash the canonical JSON object with ordered fields
sha256, size, chunks. Each chunk has index, sha256, size in that
order. Clients can independently verify the sealed manifest_sha256 against
their source and ordered chunks. Browser byte uploads use the existing
ownership-proved WebSocket channel, not raw HTTP chunk writes. Browser
credentials cannot register external archives or transfer explicitly identified
EML. No mailbox endpoint accepts an arbitrary daemon-host path.
Conflicting immutable declarations, changed resume settings, and changed targets return 409. Invalid input and capacity limits return 422. Oversized request bodies return 413. Unexpected server failures return 500. Preview samples messages. The background job verifies all ZIP entries before publishing and retains their hashes for resume. See Mailbox archives for retention, capacity, and retry guarantees.
Shape¶
docbank daemon run serves the API with Huma v2 and
the humago (stdlib net/http) adapter. It publishes a typed OpenAPI contract
(docbank openapi, or GET /openapi.json / /openapi.yaml / /docs
on a running daemon) and authenticates with X-Api-Key /
Authorization: Bearer. Endpoints are filesystem-shaped and live under
/api/v1:
| Endpoint | Purpose | Status |
|---|---|---|
GET /nodes/{id} |
stat by id (live or trashed) | Implemented |
GET /path?path=/a/b |
stat by virtual path | Implemented |
GET /formats/capabilities?family=&format=&extension= |
read the running binary's per-format capability snapshot | Implemented |
GET /nodes/{id}/children |
list a directory, paginated (limit/offset) |
Implemented |
GET /nodes/{id}/content |
stream document bytes with catalog identity and a computed digest trailer | Implemented |
PUT /nodes/{id}/content |
replace raw content under revision, size, and digest preconditions (see addendum) | Implemented |
POST /nodes/{id}/revert |
create a new head from a prior version of the same file | Implemented |
GET /nodes/{id}/versions |
list immutable content versions newest-first, paginated (limit/offset) |
Implemented |
GET /nodes/{id}/provenance |
inspect immutable ingest-origin facts newest-first, paginated (limit/offset) |
Implemented |
POST /nodes/{id}/provenance |
append an immutable origin fact under the node revision | Implemented |
GET /photos/assets/{asset_id} · GET /photos/nodes/{node_id}/asset |
inspect one bounded photo graph by asset or member node | Implemented |
POST /photos/assets/query |
list matching assets once each with a first-page total and keyset cursor | Implemented |
GET /photos/assets/{asset_id}/previews/{generation_id} |
read verified bytes for an eligible display preview | Implemented |
POST /photos/assets · POST /photos/assets/{asset_id}/files · DELETE /photos/assets/{asset_id}/files/{file_id} |
create, attach, or detach photo membership | Implemented |
POST /photos/assets/{asset_id}/exclude · POST /photos/nodes/{node_id}/promote |
change exclusion or promote a live file | Implemented |
POST /photos/assets/{asset_id}/trash |
atomically move every live photo member to recoverable trash | Implemented |
PUT /photos/assets/{asset_id}/display · GET\|PUT /photos/settings |
set an asset display override or vault preference | Implemented |
GET /people · POST /people |
list, search, and create active canonical people | Implemented |
GET /people/by-id/{person_id} |
inspect one person, its identities, and its external UIDs | Implemented |
PATCH /people/by-id/{person_id} · POST /people/by-id/{person_id}/retire |
rename or retire one person under If-Match |
Implemented |
POST /people/by-id/{person_id}/merge · POST /people/by-id/{person_id}/split |
merge or split person authority under revision and operation fences | Implemented |
GET /versions/{version_id} · GET /versions/{version_id}/content |
inspect or stream one immutable version by stable UUID | Implemented |
GET\|POST /versions/{version_id}/email |
read or synchronously ensure canonical email metadata for one immutable version | Implemented |
GET /versions/{version_id}/email/generations/{generation_id} |
read one immutable email generation attached to that version | Implemented |
GET /versions/{version_id}/email/generations/{generation_id}/parts/{part_path}/{role} |
stream one verified raw-header, decoded-payload, or UTF-8 body artifact | Implemented |
GET /content-references?sha256=&limit=&offset= |
find every stable node/version pair retaining a content hash | Implemented |
GET /duplicates?limit=&offset= |
list live current documents sharing content, with exact counts and bounded reference previews | Implemented |
GET\|POST /tags · GET /tags/by-name · GET\|PATCH\|DELETE /tags/{tag_id} |
list, resolve, create, rename, or delete stable tag definitions | Implemented |
GET /nodes/{id}/tags · GET /tags/{tag_id}/nodes · PUT\|DELETE /nodes/{id}/tags/{tag_id} · PUT\|DELETE /path/tags/{tag_id} |
inspect and change tag assignments | Implemented |
GET\|POST /saved-queries · GET\|PATCH\|DELETE /saved-queries/{saved_query_id} |
list, create, inspect, edit, or delete named query and literal highlight definitions | Implemented |
POST /saved-queries/{saved_query_id}/runs |
execute one saved query at the revision named by If-Match and return its durable run receipt with the first snapshot page |
Implemented |
POST /queries/parse |
validate complete QueryV1 intent and resolve dependency revisions without executing a search | Implemented |
POST /workspace/queries |
execute complete QueryV1 intent and return the first page of one exact bounded result snapshot | Implemented |
POST /workspace/queries/{id}/pages |
read another page from an existing query snapshot with its opaque cursor | Implemented |
GET\|POST /search-exports · GET /search-exports/{id} · POST /search-exports/{id}/dates · POST /search-exports/{id}/revisions · POST /search-exports/{id}/download · GET /search-exports/{id}/csv · GET /search-exports/{id}/bundle |
create dated search counts, review frozen evidence, download artifacts, and list recent exports | Implemented |
POST /audit/preview · POST /audit/enable · GET /audit/status |
review permanent first-scope retention, enable the reviewed plan, and inspect authority or membership | Implemented |
GET /audit/history?path=&node_id=&limit=&cursor= |
read one audited node's canonical newest-first event timeline with a stable continuation cursor | Implemented |
GET /audit/scopes/{scope_id}/history?limit=&cursor= |
read canonical newest-first events across every member of one permanent scope | Implemented |
POST /audit/verify |
independently replay audit authority, optionally prove recorded evidence is an exact prefix, and re-hash every protected blob | Implemented |
POST /nodes/{id}/verify |
re-hash one file, bound to an inspected node revision | Implemented |
GET /search?q=&tag_id=&mime_type=&under_node_id=&modified_since=&modified_before=&limit= |
bounded name and extracted-content search (FTS5), optionally restricted by stable tag identity, current base media type, descendants of a live directory, and current node modification time, with match source and truncated status |
Implemented |
GET /processing/profiles · POST /processing/plans |
list executable profiles / preview one source version and its provider disclosures | Implemented |
POST /processing/jobs · GET /processing/jobs/{id} |
run a reviewed plan with streamed job identity / read aggregate status | Implemented |
POST /processing/consent/grants · POST /processing/consent/revocations |
grant reviewed profile consent / revoke this operator's processing consent | Implemented |
GET /renditions/{attachment_id} · POST /renditions/select |
stream retained sanitized Markdown by attachment or source selector | Implemented |
POST /evidence/windows |
read bounded text from an exact current/live file and active rendition | Implemented |
GET /coverage?profile=&vault_uid=&content_version_id= · POST /search |
inspect separate rendition/embedding coverage / search an authorized source-version set | Implemented |
POST /search/similar |
stored-vector neighbors | Implemented |
POST /derivatives/purge-plans · POST /derivatives/purge-jobs |
preview / run a live derivative purge without changing immutable backups | Implemented |
POST /nodes · POST /path/mkdir |
create a directory beneath a stable parent ID or at one exact virtual coordinate | Implemented |
POST /ingest · POST /ingest/stream · POST /ingest/preflight |
import with JSON or streamed progress / inventory server-side paths (see addendum) | Implemented |
GET /collections · GET /collections/{id} · GET /collections/{id}/members · GET\|PUT /collections/{id}/label |
browse live ingest-run membership and inspect, set, or clear its label under a revision precondition | Implemented |
GET /collections/{id}/quality |
inspect bounded document distributions and processing coverage for one collection | Implemented |
POST /uploads?parent_id=&name= |
stream one digest-checked remote file (see addendum) | Implemented |
PATCH /nodes/{id} |
move and/or rename, including resolving an absolute dest_path transactionally |
Implemented |
POST /path/move · POST /path/trash |
move / trash by virtual path, resolved and mutated in one store transaction | Implemented |
POST /batch/move |
validate and apply up to 1,000 moves as one final-state transaction | Implemented |
POST /batch/tags · POST /batch/tags/preview |
atomically assign/remove one tag on a selected set of nodes checked against their revisions, or inspect its exact membership | Implemented |
POST /nodes/{id}/trash · POST /nodes/{id}/restore |
soft delete / recover | Implemented |
GET /trash · POST /trash/empty {run, older_than} |
list nodes, grouping photos on paginated requests / report or hard-delete eligible complete groups | Implemented |
POST /gc {run} · POST /verify |
reclaim unreachable blobs / validate metadata and re-hash all blobs | Implemented |
GET /storage · POST /storage/pack · POST /storage/repack |
inspect usage / pack loose blobs / compact sparse packs | Implemented |
GET /jobs |
inspect daemon-owned background tasks and terminal failures | Implemented |
GET /watches |
inspect effective watched-inbox configuration and runner state | Implemented |
POST /backup/init · POST /backup/snapshots · POST /backup/snapshots/stream · GET /backup/snapshots |
initialize a repository / create with JSON or streamed progress / list snapshots | Implemented |
Format capability coverage¶
GET /formats/capabilities returns the flat format-coverage/v1 object:
contract_version, formats, pending, and generated_by are top-level
members. Supplying format or extension also adds lookup. family filters
returned format rows, while lookup still resolves against the full runtime
snapshot. generated_by.catalog_rows continues to describe that complete
source catalog.
family and format are limited to 64 bytes; extension is limited to 16.
Use either format or extension. Supplying both returns
422 invalid_format_query. Pending and unknown queries return status 200 with
lookup.match set to pending or unknown_format. See
Format coverage for the capability and state contract.
Search accepts an optional q query. An omitted, empty, or whitespace-only
query requires tag_id, modified_since, or modified_before. This is a
request rule. limit bounds the response size, not the database work.
Filter-only hits include live files and directories except the vault root.
They are ordered by current modified_at descending, then name and ID, and
identify their source with match: "filter". mime_type and under_node_id
narrow a page but cannot anchor a blank query alone. A blank unanchored query
returns 422 search_query_required.
Tag selection uses the tag index. Time-window selection has a live-node index
matching the result ordering. Combined filters can still require substantial
work before reaching limit. The normal limit and truncated contract
remains in force, without a cursor. If truncated is true, the page is
incomplete. Narrowing time bounds cannot split a group with identical
modification timestamps, such as nodes restored together.
Document processing¶
The routes below use the normal API authentication. Plan and grant
requests use a source selector and a reviewed plan fingerprint instead of
If-Match. Browser sessions can use the built-in processing, consent,
coverage, search, and attachment-read routes. Selector reads and derivative
purge require the master API credential. A rendition is a derived, readable
representation of a document.
GET /processing/profiles returns an array of executable profiles with name,
fingerprint, rendition, and embedding_bindings. An empty array means no
profiles are executable. Choose one of these names. The default configuration
has none.
POST /processing/plans accepts a selector:
{
"selector": {
"node_id": 231,
"content_version_id": "11111111-1111-4111-8111-111111111111",
"profile": "private"
}
}
Replace these synthetic values with an inspected node, its immutable version,
and an executable profile. Node IDs are positive integers; version IDs are
canonical UUIDv4 values. Profile names have 1–128 characters, start with a
lowercase letter, and contain only lowercase letters, digits, _, or -.
The response includes fingerprint, vault_uid, selector,
profile_fingerprint, flow, disclosed_classes, retained_classes,
estimate, consent_required, consent_state, and backup_consequence.
Each flow identifies the provider, capability, trust boundary, input classes,
any filename disclosure, and a runtime_disclosure object. Review it before
granting consent or starting work. The runtime disclosure contains:
| Field | Meaning |
|---|---|
immediate_processor, ultimate_processor |
The adapter or processor receiving the input and the processor it ultimately uses |
endpoint, deployment |
The configured destination and deployment identity; a local provider reports in-process |
model, model_revision, vector_space |
Model and vector-space identities, when applicable |
metadata_classes |
Metadata disclosed alongside the content or query |
retained_artifact_roles |
Artifacts retained for this hop; query embedding retains none |
These values enter the plan fingerprint. Consent state is advisory and does not authorize a provider call by itself.
Remote recording references¶
POST /api/v1/media/sources also accepts a JSON remote recording reference.
reference_url is required and write-only. It is the protected acquisition
input, including any occurrence-specific query. canonical_url is optional,
write-only, and limited to 8,192 bytes. It is the caller's sanitized identity
URL. It must be an absolute HTTP(S) URL without userinfo. Docbank lowercases
the scheme and hostname, converts Unicode domains to punycode, and removes
the default port and fragment. An empty path becomes /. A trailing dot in
the hostname stays distinct. Other path and query encoding stays unchanged.
These rules determine the permanent source identity.
{
"operation_id": "00000000-0000-4000-8000-000000000451",
"reference_url": "https://private.invalid/share/call?token=synthetic",
"canonical_url": "https://recordings.invalid/share/call?clip=2",
"occurrence": {
"ref": "call-1",
"revision": "1",
"filename": "call.wav",
"message": {}
}
}
When the daemon has a registered self-hosted Cap origin, an exact /s/<id>,
/embed/<id>, or SDK /dev/<id> path on that origin takes the registered
branch. The source identity is the Cap video ID, so these routes identify one
source. A present canonical_url must name the same registered origin and
video ID. Otherwise the request returns 422 invalid_media_plan. A nonempty
provider_hint must equal cap.self-hosted. A different hint also returns
422 invalid_media_plan. A credential binding must be empty or the binding
registered for that origin. A foreign binding returns
422 credential_cross_origin.
GET /api/v1/media/origins reports adapter_contract,
deployment_revision, probe_state, and probed_at when a registered
origin has a probe. Probe results cover the configured network and TLS policy
at daemon startup. They do not refresh during the process lifetime.
deployment_revision is the operator's configured value, not a remotely
verified version. Recognized self-hosted Cap references remain
access_required, and acquire: true returns 503 capability_unavailable.
Registration affects new submissions only. A generic source retained before registration remains separate, and its manual import remains unchanged.
Outside registered self-hosted origins, the canonical path does not resolve
DNS, follow redirects, read credentials, or download a recording.
provider_hint is a bounded replay value and does not prove a provider.
A fresh canonical submission returns outcome: "unsupported" with a pending
occurrence. A submission that omits canonical_url uses its configured origin
policy and returns access_required. Acquisition planning uses the configured
origin and reference_url. canonical_url does not affect its plan.
The canonical URL selects the provider identity:
- Cap Cloud. A
canonical_urlonhttps://cap.soorhttps://www.cap.sowhose path is exactly/s/<id>,/embed/<id>, or the documented SDK/dev/<id>route uses thecapprovider identity, keyed by that video ID. Either host, either route, and any query or fragment select the same source. The ID is case-sensitive and must not be percent-encoded. The submission returnsoutcome: "unsupported"whether or notacquireis set, because Cap documents no download route for received links. Import the file with the artifact route described below. - Loom. A
canonical_urlonhttps://loom.comorhttps://www.loom.comwhose path is exactly/share/<id>or/embed/<id>uses theloomprovider identity. The route is part of the source key because Loom does not document equality between share and embed references. Queries and fragments do not affect identity. Loom returnsoutcome: "unsupported", even withacquire: true, and uses no network access. Import the caller's exact MP4 and SRT files with the artifact route. - Any other URL. The generic
urlidentity uses the whole canonical URL.acquire: truereturns503 capability_unavailable.
Docbank recognizes Cap links from the URL alone. It does not check whether a
Cap video is public, private, or password-protected, and it does not contact
Cap. Other Cap paths keep the generic url identity.
{
"operation_id": "00000000-0000-4000-8000-000000000461",
"reference_url": "https://cap.so/s/synthcap01?t=synthetic",
"canonical_url": "https://cap.so/s/synthcap01",
"acquire": true,
"occurrence": {
"ref": "cap-1",
"revision": "1",
"filename": "cap.wav",
"message": {}
}
}
To add a local original, send exactly the two multipart parts required by the
artifact route, with metadata kind: "media". WAV and MP3 keep their existing
rules. A remote recording may use an MP4 named .mp4 with video/mp4. MP4
files are limited to 20 MiB, 2,088,960 coded pixels, 300,000 milliseconds,
and 18,000 frames. The metadata's filename and media type must agree with the
inspected bytes. The service checks the declared size and SHA-256 before
publication.
The store publishes the verified original and binds it to the selected visible
occurrence in one transaction. A pending occurrence receives a new source
version, while an existing exact version can be reused without adding a
revision. A changed original for a bound occurrence returns
409 source_conflict. A caption or transcript must follow the original. A
caption with application/x-subrip stays a retained input until the caller
selects the supplied-captions profile. That profile publishes timed
media-transcript/v1 evidence and supports lexical and auto search. The retained
origin label does not restrict caption processing: imported files produce
supplied evidence, just as imported transcripts do. See the
caption timing limits.
Semantic and hybrid search are not configured for supplied captions. A
transcript requires a processing plan, consent, and retry before it can
produce transcribed coverage.
Media retry selects the caller's newest visible occurrence for the source. It cannot target an older occurrence. To process an older recording, use the ordinary processing API with that recording's node and current content version.
Status and list responses select the source version bound to the visible
occurrence they report. They show content_available and unprocessed after
the original is retained, then use processing receipts for that same source
version. A processing receipt's state comes from its processing job when read.
A failed retry can leave an earlier successful transcript visible for the same
version. A transcript from an older version cannot cover newer bytes.
Raw URLs and credential bindings never appear in receipts, errors, logs,
renditions, search results, or portable metadata.
Remote recording receipts¶
GET /api/v1/media/operations/{operation_id} returns the receipt saved for one
remote-recording submission under the daemon's media principal. Use it when a
POST /api/v1/media/sources reply was lost: the operation ID is enough, and the
caller never resends the protected link. An unknown ID, an ID owned by another
principal, and an ID from any other media operation all return 404 not_found.
A malformed ID returns 422. Authenticated responses, including 404 and 422,
carry Cache-Control: no-store. A 404 describes only that read. A submission
still in flight can commit afterward.
Exact media transcript reads¶
Read one retained transcript by its complete stable identity:
GET /api/v1/media/sources/{source_id}/versions/{source_version_id}/transcript?content_version_id={content_version_id}
Authentication supplies the vault and principal. The source version and
expected content version must match the caller-visible occurrence. The
response carries evidence_state, coverage_state, and operation_state
separately. Only evidence_state: "ready" includes transcript. The units are
the normalized evidence of the active rendition for that content version and
processing profile, the same build /api/v1/renditions/select serves. Each
unit keeps its text, an optional speaker, and an optional time_span with
start_ms and end_ms, the same shape search evidence uses. Untimed units
omit time_span. origin is supplied when the build came from a supplied
transcript or caption, and generated otherwise.
transcript.build_id identifies that exact active build. Supplied evidence also
includes transcript.supplied_input_id, the input bound to that build. A failed
retry keeps the identities of the earlier covering build. Generated evidence
omits supplied_input_id. Responses without ready evidence omit transcript.
A content version that no longer matches the source version or is no longer
the file's current version, or a build whose supplied input this source
version cannot see, returns stale with no text. Unknown or hidden sources
return 404 and invalid identities return 422.
Processing consent¶
To grant consent without running document processing, send
POST /processing/consent/grants with the plan's exact selector and its
fingerprint as plan_fingerprint. An optional expires_at must be a future
RFC3339 timestamp. Omitting it grants consent without expiry. The response
returns plan_fingerprint, profile_fingerprint, and any expires_at.
The grant covers this operator's use of the profile across documents and
searches. It includes the profile's document inputs, retained classes, and
query_text for providers that support query embedding. A configured reranker
adds query_text_and_excerpt to the same reviewed approval. These operations
have separate grant records and are approved together. Consent is not limited to
the document used for preview. The daemon uses the daemon:operator principal
and document-processing scope for these routes.
POST /processing/consent/revocations takes no body and returns revoked_at.
It revokes this operator's processing grants across all profiles. A new grant
must use a reviewed plan. Revocation does not delete existing derivatives.
Semantic and hybrid searches require active query_text consent for the
selected binding's provider disclosure. When rerank is true, every mode also
requires a separate query_text_and_excerpt grant for the reranking provider.
Lexical and auto searches read retained local text without query embedding or
query-text consent. Provider work checks consent before egress and before
publication. Having stored vectors alone does not authorize a query disclosure.
Start work and recover its status¶
POST /processing/jobs accepts selector, plan_fingerprint, and consent.
The fingerprint must match the current plan. consent: true grants the reviewed
profile consent without expiry before running. false relies on existing
active grants. The HTTP API permits both. The CLI requires --consent.
Once work is accepted, the response is 200 application/x-ndjson with
Cache-Control: no-store. A complete stream has two records:
sequence: 1,type: "job", andjobwith the durableid, source version, profile fingerprint, and any known rendition, attachment, or embedding IDs.sequence: 2,terminal: true, and eithertype: "status"withstatus, ortype: "error"with the job andprocessing_status_unavailableerror.
A terminal status can report failure, so HTTP 200 alone does not mean
processing succeeded. The first job receipt does not report a state or phase.
Read state and failure_code from a status response. Once accepted, work
continues under the daemon lifecycle after the requesting connection closes.
Keep the first job ID if the stream ends early and call
GET /processing/jobs/{id}. That read returns job_id, state, phase,
embedding_job_ids, completed_bindings, and any failure_code. Job IDs are
lowercase SHA-256 strings.
Read a rendition¶
GET /renditions/{attachment_id} reads one active sanitized-Markdown attachment.
Its optional max_bytes query accepts 1–67,108,864 and defaults to 67,108,864.
Browser sessions must omit query parameters on this route. Setting max_bytes
requires the master API credential.
POST /renditions/select accepts selector and a required max_bytes in the
same range, so callers can read without first discovering the attachment ID.
Attachment IDs are lowercase SHA-256 strings.
The response is text/markdown; charset=utf-8 with Cache-Control: no-store.
X-Docbank-Rendition-Attachment, -Build, -Artifact, -Profile,
-Completeness, and -Warnings identify the result. X-Docbank-Content-Version,
X-Docbank-Blob-Hash, and X-Docbank-Blob-Size identify its source version and
complete artifact. Read to completion and compare the byte count and SHA-256
with those headers and the base64 SHA-256 Content-Digest trailer before
accepting the bytes. An incomplete stream or missing digest is not verified.
The GET route accepts one Range: bytes=... and returns 206 with
Content-Range. Artifact identity headers still describe the complete
rendition. The digest trailer covers only the returned range. Invalid or
unsatisfiable ranges return 416 invalid_rendition_range.
See the Markdown contract
for the envelope and body-relative navigation.
Exact evidence windows¶
POST /api/v1/evidence/windows (readEvidenceWindow) reads an exact cited
sanitized-Markdown rendition. It requires the ordinary API key and a JSON
request of at most 16 KiB. The character and identity limits keep its JSON
response below 128 KiB.
Supply vault_uid, positive node_id, content_version_id, content_sha256,
rendition_attachment_id, build_id, and rendition_sha256. All identities
are required. Hashes and build/attachment IDs are lowercase 64-character hex.
Obtain these identities from file-version and rendition metadata, then retain
them together. An existing stored build alone does not authorize a read.
offset is a nonnegative Unicode scalar offset. Omitted or zero max_chars
selects 8,000. Explicit limits are 1 through 16,000. The response repeats every
identity and supplies text, actual_start, exclusive actual_end,
next_offset, eof, UTF-8 response_bytes, and media_type: "text/markdown".
An offset at EOF returns empty text and eof: true.
The daemon checks request syntax, vault identity, current/live node and version,
content hash, active attachment, build, Markdown artifact hash, then range.
A well-formed reference that no longer matches visible authority returns
404 evidence_unavailable without text or details about hidden components.
Invalid JSON or references rejected by the shared reader return 400. Schema
violations, including unknown fields, return 422. Offsets beyond EOF return 416.
Unavailable physical storage returns 503 with the standard content_missing,
store_fenced, or store_unavailable code. Cancellation and actual integrity
or cleanup failures are preserved. Reads do not run processing or choose a
newer version or rendition.
A rename preserves the citation. Content or rendition replacement, trash, pruning, and retirement invalidate it. Digests identify catalog authority. A partial window does not claim fresh whole-artifact hash verification. Search segment offsets are not Markdown offsets. Begin an overview at zero, or continue using offsets returned by an earlier evidence window.
POST /api/v1/renditions/windows retains its existing request fields, defaults,
and current/live visibility checks. Both routes also require a nonempty source
blob hash and check active rendition authority in the same catalog snapshot.
The stricter evidence route adds mandatory identity preconditions to the same
bounded reader.
Similar documents¶
POST /api/v1/search/similar accepts selector, optional binding_id,
optional limit from 1 to 100, and fence. The selector identifies a current
live file by node_id, content_version_id, and configured profile. The
normalized fence must contain that version before coverage is inspected.
The report has state, source, binding_id, coverage, results, and
truncated. state: unavailable includes missing_coverage with kind
embedding, binding, profile fingerprint, and source version. It has no
results. state: ready can have an empty result list.
Each result carries a rank, score, path, document identity, blob_hash,
duplicate_count, and one embedding evidence reference. The source node is
excluded. Eligible copies with the same bytes share one result. The limit
applies after grouping. L2 scores are negative distances, so higher wire scores
always rank first. Invalid source identity, stale source, storage failures,
and stale index errors remain errors. This read starts no processing and
makes no provider request. Browser sessions may POST this exact path with an
empty query string.
Coverage and source-fenced search¶
GET /coverage takes profile, vault_uid, and repeated content_version_id
query parameters. It reports profile_fingerprint, aggregate state, and
separate renditions and embeddings classes. Each class reports its name,
required status, state, and complete, unavailable, stale, ineligible, and total
counts, plus rebuilding and previous_generation_serving. A rebuilding cell
is not also counted as complete. previous_generation_serving is the subset of
rebuilding cells whose prior complete result remains available. A queued first
build therefore has rebuilding: 1 and previous_generation_serving: 0.
The rendition and embedding counts come from one catalog snapshot, including
current source visibility and serving evidence. Missing required coverage keeps
the aggregate state partial even if another class is rebuilding. This read
does not grant consent or start provider work.
POST /search uses JSON, separately from ordinary lexical GET /search:
{
"query": "renewal terms",
"mode": "lexical",
"profile": "private",
"limit": 50,
"content_first": true,
"fence": {
"vault_uid": "22222222-2222-4222-8222-222222222222",
"content_version_ids": ["11111111-1111-4111-8111-111111111111"]
},
"explain": true,
"rerank": true
}
The fence object is the source fence: the vault UUID and the content
versions the request is authorized to read. Use the actual vault UUID and
1–4,096 distinct canonical UUIDv4 versions for both coverage and search. A
foreign vault or invalid source fence is rejected. /api/v1/search request
bodies are bounded to 16 MiB.
Search requires nonblank query text of at most 8,192 characters, profile,
and mode (lexical, semantic, hybrid, or auto). limit defaults to 50
and accepts 1–100. binding_id selects the embedding binding. Omitting it uses
the profile's first binding. Set it explicitly for semantic/hybrid search when
several are configured. The CLI requires that choice. auto uses lexical
retrieval. See processing consent before choosing a mode
that embeds query text.
For exact recording attribution, add media_sources, up to 4,096 distinct
objects. Omission keeps ordinary search; empty arrays and null are rejected.
Each object names source_id, source_version_id, and content_version_id. Every
content version must belong to the fence. Optional supplied_input_ids, up to 64 IDs,
permits the selected supplied transcript only when its exact input ID is in the set.
Omitting the set permits any selected input; an empty array permits generated
transcripts only. Generated transcripts remain eligible in every case. IDs are
canonical lowercase SHA-256 identities; null is rejected. Excluded selections
make coverage incomplete and consume no ranking budget. Constraints are not
echoed in evidence source identities. Only selected transcripts contribute
to results and coverage; fence members without a selector are excluded.
Source and source-version IDs are nonempty UTF-8 bounded to 256 bytes. Use lexical or
auto without reranking. Docbank selects each source's covering transcript
before ranking and limits, including while a retry is pending.
These reports include media_source_selection: true, even without matches.
media_selections lists each final-stable eligible source tuple with its actual
origin, supplied_input_id when supplied, and completeness, independent of
query matches. Unready or input-excluded sources are omitted; an empty selection
summary is []. Ordinary search omits the summary.
Each selected content/build pair has its own result. Its evidence carries
media_sources and build_id, alongside the existing segment, excerpt and timing. Coverage counts
content versions and is incomplete while any requested selection lacks
ready evidence. Pending or unavailable transcripts leave ready matches usable.
Unknown, hidden, deleted or changed selections contribute incomplete coverage;
their associations are removed while healthy matches remain usable.
Consumers require the selection marker to distinguish older producers.
Set optional content_first: true to prefer retained content matches, including
transcripts, before filename-only matches. Selection applies the source fence
before cutoff and keeps content evidence when a filename also matches.
Omission and false keep filename priority. Auto and lexical use this order.
Hybrid uses these lexical ranks alongside semantic ranks. Pure semantic accepts
the option without changing vector results. Optional reranking can reorder
the selected candidates. POST /search/validate accepts the same preference.
The response includes requested_mode, actual_mode, coverage,
degradations, results, truncated, and trace (explain: true populates
the trace). An opted-in request also returns a reranking receipt with
applied, degraded, or skipped, a bounded candidate count, and a cause for
degradation. Each result retains its vault, node, and content-version identity
with bounded evidence references. The source fence applies before retrieval.
Vector scoring uses only eligible rows from current, live attachments.
Consumers still check visibility immediately before displaying a result.
See Processing search for the consumer contract.
The reranking degradation causes are authorization_denied, timed_out,
malformed_output, and unavailable. malformed_output covers invalid provider
scores and local inputs that cannot be reranked: a candidate with no readable
excerpt or a query that exceeds the reranking size limit. Those local failures
occur before any reranking provider call. Under degrade, the response keeps
the original ordering and includes reranking_degraded in degradations.
Under fail_closed, the request fails with reranking_failed.
Processing errors and derivative purge¶
Before a stream starts, failures use the normal application/problem+json
envelope. Common processing codes are:
| HTTP status | Code | Meaning |
|---|---|---|
| 428 | processing_consent_required |
No matching grant; review and grant consent |
| 412 | processing_consent_expired, processing_consent_revoked |
Existing consent cannot authorize this operation |
| 409 | processing_plan_changed |
Preview the changed source or profile again |
| 409 | rendition_operator_required |
Processing needs operator intervention |
| 422 | processing_profile_unavailable, foreign_vault, version_node_mismatch |
Profile or source identity does not match |
| 422 | invalid_processing_consent_expiry, search_query_required, validation |
Invalid expiry, blank query, or request schema violation |
| 422 | rendition_failed |
Required rendition work failed |
| 404 | not_found |
Requested node, version, job, or active rendition is absent |
| 503 | processing_unavailable |
The processing service is not configured |
| 500 | processing_failed |
An otherwise unclassified processing failure |
After the first job event, inspect the terminal event and recover through the
status route if it is missing or reports processing_status_unavailable.
POST /derivatives/purge-plans previews content_version_ids, attachment_ids,
build_ids, or all: true. Each ID list is limited to 1,000 entries.
POST /derivatives/purge-jobs takes the same selection plus the returned
plan_fingerprint. It returns one terminal NDJSON result event with a receipt.
Partial or deferred cleanup includes an error beside that receipt. Stale plans
return 409 derivative_purge_plan_changed. Invalid selections return
422 invalid_derivative_purge. Immutable backup copies remain untouched.
Query compilation preview¶
POST /queries/parse accepts one QueryV1 object and returns its canonical
query, query_fingerprint, and dependencies (kind, stable id, and
observed revision). Definition reads share one read transaction. No result
rows, membership snapshot, or executable SQL are returned. The endpoint is
available to authenticated API clients and browser sessions. Browser requests
must use POST without query parameters.
The compiler preserves expression scope and separate structured filters.
Missing references, including negative tag and collection operands, fail with
422 invalid_query. Expression errors include a position object with
half-open UTF-8 byte offset and end. Backend failures remain server errors.
See query grammar and limits.
Existing /search requests do not gain advanced syntax through this endpoint.
Exact query snapshots¶
POST /workspace/queries executes one strict QueryV1 payload and returns HTTP
200 with its first page. The request accepts query, an optional configured
processing profile, page_size of 50, 100, or 250 (default 100), and any
subset of collections, tags, media_family, extension, modified,
size, text_coverage, and duplicates facets.
The response freezes the canonical query, dependency revisions, selected
lexical generation and processing coverage, ordered row metadata, and exact
node/content-version membership observed at creation. It also carries exact
total and total_bytes values, member_hash, snapshot_fingerprint,
snapshot_id, and creation and expiry times. Later edits, moves, tag changes,
content replacements, or dependency changes do not rewrite an existing
snapshot. Create another snapshot to observe them.
POST /workspace/queries/{id}/pages accepts only the required cursor. Send
the returned next_cursor or previous_cursor unchanged. It is bound to the
snapshot, owner, ordering, direction, and page size. The response omits a
directional cursor when no page exists in that direction. An empty snapshot
therefore needs no page request.
Requested facets use the same creation-time read snapshot. A facet omits only
its matching outer structured filter so clients can see alternative values.
Expression operands and nested saved-query scope stay in force. total counts
distinct documents in that self-excluded population, missing counts documents
without a value, and other sums value counts omitted from the response. A
document with several tags or collections contributes once to each value, so
those value counts need not sum to total.
Each available facet keeps its leading 50 values plus selected QueryV1 values
outside that set, including a selected value whose count is zero. Size uses the
fixed <1 MiB, 1–10 MiB, 10–100 MiB, 100 MiB–1 GiB, and >=1 GiB buckets.
Modification time uses UTC calendar-month buckets. Fixed categorical facets
include their defined zero-count buckets. A facet that cannot be computed
within its coverage, member, or time budget returns available: false with a
reason and omits count fields. It is not a successful zero.
Materialization admits at most 250,000 rows and 64 KiB of serialized data per row. Across the daemon, the cache admits at most 1,000,000 rows and 512 MiB of serialized snapshot data, with at most eight handles per authenticated owner and two builders at once. A build has 30 seconds. All requested facets share a five-second facet budget. Handles expire after 15 minutes idle or 30 minutes absolute lifetime. These are cache-admission bounds, not a claim that the process uses at most 512 MiB of RSS.
Snapshot ownership follows authentication. A browser token can read only its own handles, and revoking the browser session invalidates them. Master-key requests share the daemon's master owner. All handles are daemon-lifetime cache state and disappear on restart or shutdown.
POST /saved-queries/{saved_query_id}/runs executes the saved query revision
named by If-Match. Its body accepts only profile, page_size, and facets.
It cannot replace the saved QueryV1 payload. The response is
{run,snapshot}. run durably records the definition revision, fingerprints,
exact totals, expiry, and comparison with the previous run. The accompanying
snapshot rows remain ephemeral: backup and restore retain the receipt but do
not reconstruct rows or silently rerun the query. Run the saved query again
after 410 snapshot_gone.
Snapshot execution supports duplicate and text-coverage constraints.
Text-coverage predicates require a configured processing profile. Semantic and
hybrid modes and relevance ordering remain unsupported and return positioned
422 invalid_query errors.
Search exports¶
Search exports freeze one observation and produce dated counts with an evidence
packet. The Search exports guide owns the version 1
request and choice formats, count definitions, evidence layout, and limits.
All paths below start with /api/v1/search-exports and require authentication.
| Method and suffix | Request and response |
|---|---|
POST / |
Version 1 request, up to 8 MiB; returns a summary with state complete or needs_review. Counts and downloads are withheld in needs_review. |
GET / |
offset (0–100) and limit (1–50, default 20); returns items containing reusable request and summary, and total. Lists vault history, including expired receipts. |
GET /{id} |
Returns the caller's live frozen summary. |
DELETE /{id} |
API-key release of an owned live handle; returns 204 without a body. ID must be 48 lowercase hexadecimal characters. |
POST /{id}/dates |
JSON {"cursor":"","limit":50,"max_bytes":262144}; limit is 1–100. Omitted or zero max_bytes defaults to 1 MiB; positive values must be 64 KiB–1 MiB. Returns members and an optional next_cursor. A page may stop within a member's candidates; check candidates_complete and continue with the opaque cursor. |
POST /{id}/revisions |
JSON {"choices":[...]}, up to 8 MiB; returns a new summary bound to the original observation and expiry. |
GET /{id}/csv |
API-key download of search-export.csv. |
GET /{id}/bundle |
API-key download of search-export.zip. |
POST /{id}/download |
Browser-session JSON {"format":"csv"} or {"format":"bundle"}; returns a one-use download url. |
Create accepts all-document, collection, or exact current-document scope. The
selected identities are admitted together in the same read observation as the
report. Stale selection returns 409 report_selection_changed. Malformed
selection or a wrong hash for a current version returns
422 invalid_report_request. Selected-member overflow returns 413 report_limit,
while collection-count overflow still returns 422. See the guide for null/empty
semantics and identity requirements.
History pages are also limited to 16 MiB. Advance offset by the number of
returned items when the byte limit shortens a page. Source deletion does not
remove a receipt or revoke an already captured report.
Release frees a handle slot for either a complete or review-pending report. It discards that handle's live packet and date evidence, while preserving history, saved files and already published child reports with their original expiry. Shared evidence remains in memory until its last user finishes. A concurrent revision can publish only while its parent remains available.
An active download prevents release with 409 report_retained. The caller may
retry deliberately after the transfer closes; no request is retried automatically.
Unknown, expired, released and other-owner handles return 410 report_unavailable.
Browser sessions cannot release reports and receive 403 web_session_read_only.
Downloading does not release a handle.
The create and history paths have no trailing slash. Date pages are limited to
1,000 candidates and the selected byte ceiling even when the requested member
limit is larger. The ceiling includes the continuation cursor. A populated
page stops before the next candidate that cannot fit, returning a cursor to
that candidate. This applies to the default 1 MiB pages too. An invalid byte
ceiling or a required first item that cannot fit returns 413 report_limit.
Direct downloads include Content-Length and X-Docbank-Report-SHA256 for the
complete artifact. Browser sessions use the one-use ticket route instead.
An export handle or cursor from another owner is unavailable to the caller.
Malformed date choices return 400 invalid_report_choice. A choice whose
document or evidence no longer matches the frozen observation returns
409 stale_evidence. Request or query validation and incomplete strict
coverage return 422. Downloading before date review returns
409 date_review_required. Expired or invalidated handles return
410 report_unavailable. Resource limits return 413 report_limit. When a
document has too many date candidates, the error names that document.
503 report_capacity means the cache is full, 503 report_timeout means the
build deadline expired, and 503 report_unavailable means the server has no
export service.
Saved query and highlight definitions¶
POST /saved-queries stores a name, optional description, immutable kind,
and one complete structured payload. kind is either query for QueryV1 or
highlight_set for an ordered set of literal text/color pairs. The response
adds a stable UUID, canonical payload fingerprint, revision, timestamps, and a
quoted numeric ETag. Payloads are JSON objects on the wire, not base64
strings.
QueryV1 stores these fields. Defaults apply when a field is omitted:
| Field | Accepted value | Default |
|---|---|---|
v |
1 |
1 |
text |
Up to 8,192 Unicode characters, preserved exactly | Empty text |
syntax |
simple or advanced |
simple |
mode |
lexical, semantic, or hybrid |
lexical |
filters |
Object described below | {} |
sort.field |
name, path, modified_at, size, media_type, relevance, capture_time, import_time, or added_time |
name |
sort.direction |
asc or desc |
asc |
The filters object accepts the following saved choices. These are storage
fields, not additional parameters for GET /search:
| Fields | Values |
|---|---|
paths, exclude_paths |
Absolute virtual paths; at most 64 in each set |
collection_ids, exclude_collection_ids, tag_ids, exclude_tag_ids |
Canonical UUIDv4 values; at most 64 in each set |
no_tags, has_duplicates, collapse_duplicates |
Boolean choices |
mime_types |
At most 64 concrete media types without parameters |
extensions |
At most 32 lowercase extensions without a leading dot |
media_families |
Array of at most 13 entries: email, document, spreadsheet, presentation, image, audio_video, text, source_code, web, calendar, archive, cad, or unknown |
modified_after, modified_before |
RFC3339 timestamps, normalized to UTC |
size_min, size_max |
Byte counts from 0 through 9,007,199,254,740,991 |
text_coverage |
Array of at most 6 entries: complete, partial, failed, unprocessed, none, or unavailable |
kinds |
At most 64 entries, photo or video |
cameras, lenses |
At most 64 complete make/model strings, matched with Unicode case folding; each 1 through 256 Unicode characters |
iso_min, iso_max |
Inclusive safe nonnegative integer bounds; zero is accepted |
capture_after, capture_before |
Strict YYYY-MM-DD local capture dates; inclusive lower and exclusive upper bounds |
gps_bounds |
Decimal-string south, west, north, east; each at most 64 characters, latitude within -90 through 90, longitude within -180 through 180, south <= north; west > east crosses the antimeridian |
focus_min, focus_max, blur_min, blur_max, brightness_min, brightness_max, framing_min, framing_max, aesthetics_min, aesthetics_max, color_red_min, color_red_max, color_green_min, color_green_max, color_blue_min, color_blue_max |
Inclusive 0..1 decimal-string bounds, each at most 64 characters; each minimum must be <= its maximum. Numeric JSON bounds return 422. Missing measurements fail numeric comparisons |
unevaluated |
true selects live, included photo display versions with pending or unavailable measurements; structured false acts as omission |
asset_ids |
At most 64 canonical UUIDv4 values |
set_ids |
At most 64 canonical album UUIDv4 values |
Quality filters follow the same version as other photo metadata filters. Photos uses the asset's selected display; Documents uses each document's own version. unevaluated:true applies only to versions eligible for measurement. Expressions reject unevaluated:false; use focus_min:0 for measured photos.
Photo browse returns quality ready with scores, unavailable with signals: null when the current grid preview is unsupported or failed or its verified bytes do not decode, and pending with signals: null otherwise.
Filter sets are sorted and deduplicated when saved. Query text is not trimmed or rewritten. Unknown fields and duplicate JSON object keys are rejected. A raw payload may use at most 128 KiB; its normalized encoding may use at most 64 KiB. Nested JSON is limited to 16 levels.
A highlight_set payload contains v: 1 and terms. Each term has text
and color. Supply 1–64 terms with unique literal text, each 1–256 Unicode
characters long. Colors must be lowercase #rrggbb. The term order is
preserved, and terms are not regular expressions. See the
creation examples.
Names are unique across both kinds. Names use Unicode NFC normalization and must contain 1–256 UTF-8 bytes afterward. Blank names and control characters are rejected. Descriptions allow at most 4,096 UTF-8 bytes. Windows CRLF line endings normalize to LF on create and update.
An omitted or null size bound is unset. size_max: 0 preserves an
empty-file bound, while size_min: 0 is equivalent to no lower bound. A
minimum greater than the maximum is rejected.
GET /saved-queries returns a consistent name-then-ID-sorted page with
items, total, limit, and offset. The default limit is 100, the maximum
is 1,000, and an optional kind selects one definition type. The stable-ID
route returns one definition and its current ETag. PATCH accepts only
name, description, and payload. Omitted fields stay unchanged, null and
an empty patch are rejected, and kind cannot change. Update and delete both
require If-Match. A canonical no-op keeps its revision and timestamp.
Replacing payload replaces the whole payload, not individual nested fields.
A stale revision returns 412 stale_revision. A successful create returns
201. Update and delete return 200 with the resulting or deleted definition
and its ETag. Saved definitions are included in metadata backup and restore.
These definition endpoints store intent. They do not execute a query,
translate QueryV1 into the current /search query string, read document
content, render highlights, or return result counts. The separate /runs
endpoint above executes a saved query. Query text is preserved exactly,
including quotes, parentheses, and Boolean operators.
Once audit is enabled for a vault, saved-definition reads remain available but
create, update, and delete return 409 audit_mutation_unsupported. The audited
history format does not yet record this mutation class, so the server leaves
every saved row and revision intact.
Saved definitions are included in current backups. Restoring an older supported backup upgrades it into the current schema with no saved definitions when none were present. A backup containing saved-definition authority must be restored with the release that wrote it or a newer release. Downgrade readability is not promised. An older reader rejects the unknown authority instead of silently dropping it.
These root-level routes are outside /api/v1 and auth-exempt: GET /health,
GET /api/ping (daemon discovery), GET /docs and the OpenAPI documents,
and /, /photos, and /assets/ (the static web application, when [web] enabled).
A hidden POST /api/daemon/shutdown (not in the OpenAPI document) backs
docbank daemon stop. It isn't auth-exempt: it requires both the API key and
its own shutdown token. The hidden POST /api/daemon/web-session exchanges
that master authority for a random daemon-lifetime browser token, an
independent upload-proof secret, and the fresh loopback origin dedicated to
that daemon lifetime. DELETE /api/daemon/web-session revokes the calling
browser session.
POST /api/daemon/telemetry/events takes one anonymous interface event with optional allowlisted properties,
such as app_opened or session_ended, from a browser session or the API key. The daemon answers
400 for any event its allowlist omits and sends nothing when telemetry is off.
screen_viewed requires a fixed screen name and surface of web or tui, and answers 400 for any other value. A daily duplicate returns the accepted receipt without capture. Each screen counts once per vault per UTC day for each interface, browser and terminal, across daemon restarts.
See anonymous usage telemetry.
Browser session tokens authenticate only the routes used by the built-in
document, tag-definition/assignment, saved-definition, recoverable-trash,
storage, job, configured-backup, verified-download, and usage-event workflows.
They are not another general API credential. Browser file bytes use the
hidden /api/daemon/web-upload WebSocket instead. The page verifies a
challenge proof over the upload secret before sending bytes, binds the socket
to one session, and never reconnects it. An ordinary browser token is
forbidden from POST /api/v1/uploads.
Attachment navigation additionally permits exact GET /api/v1/versions/{id},
GET /api/v1/email-document-publications/{operation_id}, and bounded
GET /api/v1/email-document-relations reads. Relation queries require exactly
one parent or child version, with an optional paired operation/order cursor
and a page limit of at most 250. Unknown or repeated parameters are denied.
These browser capabilities do not permit publication, removal, email
processing, or consent mutations. The inspector validates relation and receipt
agreement before presenting inventory authority and separately checks the
related version's current node access before opening verified content.
GET /nodes/{id}/children binds the live directory projection (including its
current canonical path) and the requested child page to one read transaction.
Refresh clients therefore do not combine an earlier directory name with a
later child listing.
IDs are canonical everywhere: every response carries them, and mutating endpoints address nodes by ID so a rename can't strand a concurrent client's reference.
For stable-identity moves, PATCH /nodes/{id} accepts either the lower-level
new_parent_id/new_name fields or one absolute dest_path. These forms are
mutually exclusive. The dest_path form resolves POSIX-style destination
semantics and checks If-Match in the same transaction. POST /path/move
remains the coordinate-oriented form when the source path itself is the
intended target.
Directory creation has the same identity-versus-coordinate choice.
POST /nodes accepts a previously resolved parent_id, so a concurrent parent
move does not change which directory receives the child. POST /path/mkdir
accepts {"path":"/projects/2026"} and resolves the existing parent inside
the creation transaction, so the coordinate cannot be redirected between
separate client requests. Both return the new node and its canonical path from
that transaction. Neither creates missing ancestors.
POST /batch/move accepts {moves:[...]}. Each item selects its source with
either source_path, or node_id plus the revision previously inspected by
the caller, and supplies destination_path. Every selector resolves against
one pre-transaction topology. Each destination is an exact final coordinate,
and its parent resolves against the planned final topology. Batch requests do
not apply the single-move “move into an existing directory” shorthand. Docbank
constructs and checks the complete final topology in Go before applying it, so
file and directory swaps and nested reorganizations do not depend on unsafe
intermediate names. A failure rejects the whole plan. The response preserves
request order and returns each node's prior path plus its complete final node
projection and path.
Backup repository endpoints¶
POST /backup/init accepts {"repo": "/absolute/server/path"} and returns
the repository identity and canonical path. repo may be omitted when
[backup] repo is configured. POST /backup/snapshots accepts the same
optional repository plus tag, jobs, and force_unlock. It returns a
stable logical summary rather than exposing Kit's physical manifest layout.
The /backup/snapshots/stream variant accepts the same body and returns
application/x-ndjson: zero or more progress events followed by exactly one
terminal result or error event. Progress data carries stage, item counts,
byte counts, and a final-stage marker, so clients can render bars without
parsing human text. Because response headers commit when streaming begins, an
HTTP 200 means only that the stream started. Clients must read through EOF and
require the terminal event. The CLI uses this variant for human output and the
single-JSON endpoint for --json.
GET /backup/snapshots?repo=... returns
{repository: {id, path}, items: [...]} so clients can identify the repository
behind the immutable manifests and pagination can be added later without
changing a top-level array contract. A browser session may use this GET only
without repo, which confines the web application to the daemon's configured
repository. Backup mutations and arbitrary server-path selection still require
the master API authority.
Explicit repository paths are server filesystem paths and must be absolute.
The CLI resolves a relative --repo against its own working directory before
sending it. API clients over an SSH tunnel must reason about the daemon host's
filesystem, not the caller's. Every endpoint requires the daemon API key.
Audit expected-evidence verification¶
POST /audit/verify accepts an empty body for a fresh proof. To prove ancestry,
send the evidence object from a previously successful report:
{"expected":{"vault_id":"...","lineage_id":"...","operation_sequence_high_water":12,"allocation_entry_count":12,"allocation_head":"...","scopes":[{"id":"...","entry_count":9,"chain_head":"..."}]}}
The daemon independently replays the current vault before comparison. A
successful prefix proof returns evidence_check: {"extends":true}. Evidence
disagreement remains an HTTP 200 verification report so clients can inspect
current terminal evidence and protected-byte problems together.
evidence_check.problems uses the stable codes audit_not_enabled,
vault_mismatch, lineage_mismatch, allocation_shorter,
allocation_diverged, scope_missing, scope_shorter, and scope_diverged.
Malformed expected evidence is a 422 validation request error.
Background-job status¶
GET /jobs returns {items: [...]} in stable job-name order. Each item carries
name, status (running, completed, failed, or cancelled), and a UTC
started_at. Terminal jobs add finished_at, and failures add a bounded
error. Supervised task records describe this daemon run only and disappear
when it restarts. Stopping one requires stopping or reconfiguring the daemon
feature that owns it.
Durable storage operations, such as placement and photo imports, also appear as
storage:<operation_id> items with operation_id, kind, object progress,
cancel_requested, and can_cancel while queued or running.
GET /jobs/{operation_id} returns the full operation and its receipt.
POST /jobs/{operation_id}/cancel requests cancellation at the next durable
object boundary and returns 409 once the operation is terminal or finalizing.
A browser session may list jobs and cancel an operation by its UUID. It cannot
read GET /jobs/{operation_id}, its error text is replaced with a generic
message, and the cancel response omits the receipt, so host source paths stay
with API-key clients.
GET /watches returns {items: [...]} in stable watch-name order. Each item
joins the effective machine-local source, virtual destination, settle and scan
durations, and literal exclusions with its current watch:<name> job record.
The job is omitted only when no runner has been registered, which is not an
ordinary live-daemon state. Configuration remains file-owned: this endpoint
does not create, edit, or restart watches.
Path resolution: a query parameter, not a URL segment¶
GET /path takes the virtual path as ?path=/inbox/doc.pdf, not a
catch-all URL segment (/path/{path...}). Stdlib-mux decoding of a
wildcard segment makes a route ambiguous for names containing
/-adjacent percent-encoding. A query parameter has one well-defined
encoding. The path must be absolute (leading /), and ?path=/
resolves the root. The server applies the store's existing NFC name
normalization and validation and returns 422 for an invalid path.
Batch tag assignment¶
POST /api/v1/batch/tags accepts a canonical UUIDv4 operation_id, a tag UUID
tag_id, required boolean assign, and nodes: [{node_id, revision}].
The body is limited to 1 MiB and 1–1,000 unique live file or directory IDs with
positive revisions. No query, subtree expansion, or If-Match header is
used. Invalid structure returns 422, missing or trashed targets return 404,
and a stale target returns 412 (stale_revision). Every target, including
assignment no-ops, is validated in the same transaction as all changes and
receipt persistence. Actual changes use the existing canonical audit events,
with a separate audit operation for each changed node. The receipt's operation
ID identifies the batch retry. It is not an audit operation ID.
Success returns 200 with version: 1, operation_id, request_digest,
tag_id, assign, final tag_revision, final assignment_count,
completed_at, and numerically sorted
nodes: [{node_id, expected_revision, revision, changed}]. Every requested
identity appears exactly once. Changed nodes advance by one revision.
Unchanged nodes keep their expected revision.
Request order is not semantic. The lowercase SHA-256 request digest covers
UTF-8 text: docbank-tag-batch-v1 followed by newline, the canonical tag UUID
followed by newline, 1 for assignment or 0 for removal followed by newline,
then one node_id:revision line per target in ascending numeric node-ID order.
Every line, including the last, ends in newline. Integers use unpadded decimal.
The operation UUID is a separate lookup identity, not part of that digest.
Repeating an operation with the same canonical request returns its original
receipt, even after later edits or deletion. Reusing the operation UUID with
different input returns 409 batch_tag_operation_conflict. Receipts are
retained indefinitely without node/tag cascade deletion and contain no names,
paths, or document bodies. Metadata and physical backup/restore retain receipts
present at the backup checkpoint. An earlier backup cannot know later
operations. A historical success does not assert current membership or
recreate a deleted entity. Clients must validate complete receipt identity and
revision outcomes before accepting success.
POST /api/v1/batch/tags/preview takes only tag_id and the same nodes
list, with the same limits and revision checks. One read snapshot returns
tag_id, tag_revision, and sorted nodes: [{node_id, revision, assigned}],
with no durable changes. This provides exact selected-set membership without
interpreting omissions from a truncated tag listing. Both routes require
authentication. Browser sessions permit only these exact POST paths without
query arguments.
Concurrency: resource revisions and If-Match¶
Photo assets are a revisioned graph over ordinary file nodes. Image files and
concrete video/* files enroll when created. Generic RAW files require an
explicit promote or create call. Enrollment is forward-only and uses
email_document_relations.child_version_id to exclude published email
children. Processing metadata does not make its source an email child.
GET /api/v1/photos/assets/{asset_id} and
GET /api/v1/photos/nodes/{node_id}/asset return at most 256 members, the
resolved display pointer, its source (asset, vault, default, or none),
and the asset ETag. The default order is RAW, image, then video. The vault
image preference moves image before RAW, and an asset override wins. A
sidecar must point at a same-asset RAW or image member and is never
displayable.
Create and promote operate on live file nodes. Attach, detach, exclude,
display, and settings mutations require If-Match. The store checks the
revision in the transaction and returns the new ETag. A changed human
decision writes one bounded immutable photo receipt. No-op decisions preserve
the revision and write no receipt. Permanent node deletion repairs affected
graphs and preserves an empty asset identity.
POST /photos/assets/{asset_id}/trash requires the asset revision in If-Match and moves all live members together. It returns the updated asset and ETag, recording one receipt. An asset with no live files returns 422 invalid_photo_asset. Each photo is atomic; browser selections submit sequentially and can make partial progress. Audited trash uses the existing audited node rules.
Paginated GET /trash?limit=&offset= groups independently trashed photo members into one item with optional photo_asset_id and photo_file_count, the count of trashed files recovered together. Unpaged listing remains per node. Restoring any member recovers its group, including companions inside trashed folders, under the selected member's revision. The affected roots are read and restored atomically. Ordinary Documents deletion can trash one member; permanent deletion waits until every connected member is trashed, old enough, and free of retention references. Trash the remaining members or detach live companions to make a partial group eligible.
Photo assets, file memberships, settings, and receipts are included in the deterministic metadata JSONL stream and are validated as one graph on restore. Older supported metadata streams restore an empty photo authority. Audit-active vaults skip automatic enrollment and refuse membership, display, and settings mutations while preserving any graph that existed before audit was enabled. Display and settings writes are available through HTTP and the CLI. MCP exposes them only as reads in this slice.
GET /photos/albums lists albums with counts of members that still have a file and of included members and current ready grid covers. POST /photos/albums creates one; GET /photos/albums/{set_id} inspects it. PUT /photos/albums/{set_id} changes optional name or starred. PUT /photos/albums/{set_id}/cover chooses a member through asset_id, or clears the override when omitted. POST /photos/albums/{set_id}/duplicate supplies a new name. DELETE /photos/albums/{set_id} deletes the album while retaining its photos. Existing-album writes require If-Match and return the album with its ETag.
POST /photos/albums/{set_id}/members/add and /members/remove accept either asset_ids with at most 1,000 entries or a strict query, with optional coverage. The full query scope resolves within the atomic write and, like photo browsing, covers only visible photos; removing hidden members requires their asset_ids. Changes advance the album revision once and record every changed asset ID across bounded receipts. No-ops preserve the revision. Album routes require daemon API-key access. Browser sessions can browse members through the existing photo query route with filters.set_ids, but cannot mutate or list albums. See Albums for member ordering, visibility, and cover selection.
POST /photos/imports starts a folder import from a daemon-host path and
returns 202 with the queued StorageOperation, or 422 when the path is not
an existing folder. The import is a durable job of kind photo_import.
GET /jobs/{operation_id} returns its group progress and receipt: added,
skipped, changed, failed, ambiguous and unsupported counts, plus the groups
left unpaired. POST /jobs/{operation_id}/cancel stops it before the next
group. Starting an import requires the API key on a loopback connection.
POST /photos/assets/query executes photo asset browsing. It accepts strict query, optional coverage, page_size and cursor, returning one item per eligible matching asset, total counted on the first page and optional forward next_cursor. The supported sort fields are capture_time, import_time, added_time, name, modified_at, size and media_type; default ordering is name ascending. Capture keys sort missing or unreadable evidence last, then use ascending asset UUID for ties. Text keys compare their first 1,024 characters, so names or media types that share that prefix fall back to the UUID order. Album filters constrain the matching population before choosing duplicate representatives. added_time requires exactly one normalized filters.set_ids value and uses that album's added dates. Document snapshots reject capture_time, import_time, and added_time with a field-specific error that directs callers to Photos. Later pages reuse the first page's total while rows remain live; a new browse refreshes the count. Cursors expire after 15 minutes and bind resolved saved-query revisions, effective coverage, canonical query, and page size. Invalid options return invalid_photo_query; invalid or changed bindings return invalid_photo_cursor; expiry returns cursor_expired. Invalid expressions retain their operand positions.
GET /photos/assets/{asset_id}/previews/{generation_id} returns complete verified JPEG bytes for an included asset's current display version. An unavailable or stale generation returns 404. Missing retained bytes return photo_preview_unavailable; failed byte verification returns photo_preview_corrupt. Success includes Content-Length, Content-Digest, the quoted generation ETag, X-Content-Type-Options: nosniff and Cache-Control: private, no-cache. A matching If-None-Match returns bodyless 304 after checking current display eligibility, without reopening the blob. Excluded or replaced displays return 404 even with a matching validator. Responses vary by API-key, authorization and browser-session headers. Browser sessions permit the exact list POST, preview GET, and POST /photos/assets/{asset_id}/trash with empty query strings. Preview slot states are missing, ready, unsupported and failed; only ready results carry URLs. This read never generates a derivative.
Person reads return the canonical row and, for GET /people/by-id/{person_id},
the identities and external UIDs used by split. Rename, retire, merge, and
split check the person revision inside the store transaction. Merge checks
the absorbed revision from the body. A successful merge or split advances the
document-people binding epoch, so the daemon backfill republishes derived links.
Split returns source_revision_after and an ETag for that accepted revision.
The same operation request replays its original fence after later edits because
the store binds the receipt to the original request digest. The 1 MiB request
body limit applies before decoding, so an oversized split returns
413 before the source revision changes.
Every node carries a revision that bumps on each mutation (directories
bump when their contents change). Revisions are per node because a
global tree ETag would invalidate every agent's in-flight work whenever
anything anywhere changed, while per-node revisions scope conflicts to
actual contention. SQLite already serializes the writes. Preconditions
exist to catch lost updates across an agent's read-modify-write
turns, not to lock.
Every tag definition likewise carries a revision. It advances when its name
or assignment set changes, so a client cannot rename over a concurrent rename
or delete assignments it did not inspect. Single-tag responses carry an ETag
matching this revision.
If-Match is required where a mutation targets one existing node that
the caller read in an earlier request. Path mutations, bulk operations,
and maintenance are exceptions:
| Endpoint | Precondition |
|---|---|
PATCH /nodes/{id} |
required: target node's revision; an optional dest_path is resolved in the same transaction |
PUT /nodes/{id}/content |
required: prevents a replacement from overwriting a head the caller did not inspect |
POST /nodes/{id}/revert |
required: binds the selected source to the current head the caller inspected |
POST /nodes/{id}/trash |
required: target node's revision |
POST /nodes/{id}/restore |
required: target node's revision |
POST /nodes/{id}/verify |
required: binds the evidence to the node state the caller inspected |
PATCH /tags/{tag_id}, DELETE /tags/{tag_id} |
required: tag definition/assignment-set revision |
PATCH /saved-queries/{saved_query_id}, DELETE /saved-queries/{saved_query_id} |
required: saved-definition revision |
POST /saved-queries/{saved_query_id}/runs |
required: executes the saved definition revision the caller inspected |
PUT\|DELETE /nodes/{id}/tags/{tag_id} |
required: target node revision; the tag revision also advances on a real assignment change |
POST /photos/assets/{asset_id}/files, DELETE /photos/assets/{asset_id}/files/{file_id}, POST /photos/assets/{asset_id}/exclude, POST /photos/assets/{asset_id}/trash, PUT /photos/assets/{asset_id}/display |
required: photo asset revision |
PUT /photos/settings |
required: photo library settings revision |
PATCH /people/by-id/{person_id} |
required: person revision |
POST /people/by-id/{person_id}/retire |
required: person revision |
POST /people/by-id/{person_id}/merge |
required: survivor revision; the body carries the absorbed revision |
POST /people/by-id/{person_id}/split |
required: source person revision |
POST /path/move, POST /path/trash |
none: the path is resolved and mutated inside one store transaction, so there is no separate read for a revision to guard |
POST /batch/move |
each path source resolves in the transaction; each stable-ID source carries its own required revision |
POST /nodes (create dir) |
none: creation has no prior revision; a name collision is 409 |
POST /path/mkdir |
none: the parent coordinate resolves inside the creation transaction; a name collision is 409 |
POST /ingest · POST /ingest/stream |
none: long-running bulk operations with per-path partial success; the destination directory may change while they run |
POST /uploads |
none: creates or idempotently resolves one file under the stable parent_id; name/content collision policy is transactional |
POST /trash/empty, POST /gc, POST /verify |
none: vault-wide maintenance, serialized by the maintenance gate |
POST /backup/snapshots, POST /backup/snapshots/stream |
none: mutations pause only while pinning one logical snapshot; a preservation lease queues maintenance for the full capture, and the repository has its own exclusive lock |
A stale revision gets 412 Precondition Failed, telling the caller to
re-read and retry. A required If-Match that's missing gets
428 Precondition Required. Both carry the problem-JSON error envelope
below explaining the rule.
Content identity and verification evidence¶
Every file-node representation includes a stable current_version_id plus
blob_hash, Docbank's canonical lowercase SHA-256 content identity, and raw
size. Directories omit content identity. Node and version IDs are stable
across moves and renames. Content replacement keeps the node ID, creates an
immutable version, and changes its current pointer, hash, media type, and
revision.
File nodes and versions also carry md5, an auxiliary checksum of the same
logical bytes for interoperability with tools that key on MD5. It is never an
identity. Single-node and version-detail responses include source_metadata
when the daemon has extracted metadata from the original bytes (see
Source metadata). Browser-session reads omit fields the
extractor marked sensitive, while API-key reads receive every field. Ingest
preflight reports cloud_placeholders, the files whose bytes a cloud-drive
provider has not materialized locally.
GET /nodes/{id}/content exposes the catalog identity before streaming in
X-Docbank-Content-Version, X-Docbank-Blob-Hash, and
X-Docbank-Blob-Size. It then hashes the bytes while they pass through the
response and emits the result as the
RFC 9530 Content-Digest
trailer. The response omits standard Content-Length: HTTP/1.1 cannot carry a
trailer on a fixed-length message, and pre-reading a large loose or packed
blob solely to populate a header would double physical I/O. Clients that need
independent transfer proof hash the body themselves and compare both their
digest and the trailer with the node's blob_hash. The version header must
equal current_version_id.
GET /nodes/{id}/versions returns a bounded, newest-first page with items,
total, limit, and offset. GET /versions/{version_id} resolves immutable
metadata globally, and its /content child streams that version with the same
identity headers and digest contract. A path rename cannot strand a retained
version reference.
GET /nodes/{id}/provenance returns the requested file node, its live path when
one exists, and a bounded newest-ingest-first page from one read snapshot. Each
fact carries its canonical SHA-256 identity, active/superseded state, ingest
identity and time, source kind and description, original path and mtime, and an
optional superseded-fact identity. A trashed node remains inspectable by ID but
has no live path. The route is observation only: it neither accesses the
source nor changes retention authority.
POST /nodes/{id}/provenance accepts source_kind, source_description,
original_path, an optional canonical UTC RFC3339Nano original_mtime (for
example, 2026-08-26T12:00:00Z), and an optional supersedes identity. The
caller supplies the node's current revision in If-Match. A successful
request returns 201 with the appended fact and its ETag, advances that node
revision once, and records a generic ingest alongside the fact.
original_path is opaque evidence and is never opened. A supersession must
point to an active caller-supplied fact on the same node, while the old fact
stays visible and immutable. Operational CLI and watched-folder ingest facts
cannot be superseded: they keep re-ingest idempotent. Record a newly learned
origin as an additional fact instead.
The encoded request body must be smaller than 1 MiB (1,048,576 bytes). A body
at or above that limit receives 413 before the append runs.
Email metadata and exact parts¶
Email files keep their original bytes as the immutable content version. The built-in decoder adds a separate canonical MIME inventory: ordered raw header spans, decoded fields including Bcc, message and part structure, alternatives, diagnostics, and exact SHA-256/size receipts for retained artifacts. It does not normalize the source, deduplicate messages by Message-ID, or turn attachments into child documents.
Encoded headers, addresses, and multipart structure use the Go standard library. The recipe records the Go version and fixed resource limits. A structure rejected by the parser remains a partial inventory with a diagnostic. This includes malformed part headers and multipart delimiter or preamble lines longer than 4 KiB. Original bytes and already inventoried parts remain readable.
GET /versions/{version_id}/email returns the selected generation for that
version. An eligible version still waiting for the daemon worker returns
202 with its populated version and state: "pending". An undeclared source
returns 422 email_not_supported. POST with {} explicitly attempts it
without changing its stored media type or bytes. A suppressed inventory returns
409 email_derivative_suppressed.
Generation and part URLs are immutable. A generation must be attached to the
version in the URL, and a part is selected by its dotted structural path plus
one role: raw_headers, decoded_payload, or body_utf8. Missing roles return
409 email_part_unavailable. Invalid selectors return
422 invalid_email_part. Part responses are always downloads with
application/octet-stream, nosniff, complete version, generation,
attachment, and part identity headers, expected SHA-256 and size, and an actual
Content-Digest trailer. Clients must read through verified EOF. A partial or
corrupt stream has no successful digest proof.
These routes require the master API credential. Browser-session credentials cannot read email metadata or part bytes. There is no browser email viewer, PDF rendering, attachment indexing, mailbox sync, or MBOX import in this surface.
GET /content-references is the inverse identity lookup. It accepts one
canonical lowercase SHA-256 and returns only logical content_versions
references backed by blob-catalog authority, meaning a blobs row (see
Identity, addressing, and authority).
It never infers a match from a loose file or pack entry alone. Each item
contains the complete immutable version, its current node projection, whether
that version is the node's current head, and a path only while the node is
live. Results are bounded and deterministic: live current references, live
history, then trashed references.
GET /duplicates discovers hashes shared by at least two live current file
nodes. Its items, total, and total_references describe that population.
Historical versions and trash are excluded. limit defaults to 50 and accepts
1–100. offset is nonnegative. Groups sort by canonical SHA-256, and counts
remain exact on an exhausted page. Counts, references, paths, and collection
labels share one read transaction per request.
Each group contains sha256, size, reference_count,
representative_node_id, references, and references_truncated. At most
16 references are displayed in earliest (modified_at,node_id) order. Each
reference wraps the existing content-reference identity in reference, plus
collections (up to 16 distinct eligible IDs and optional labels),
collection_count, and collections_truncated. Collection identities sort
by ID. Multiple import memberships never multiply document counts. The read
is available to API-key clients and browser sessions. Browser access permits
only GET on the exact route, with optional singleton limit and offset
parameters. There is no duplicate-deletion operation.
See duplicate discovery for pagination and historical-lookup guidance.
POST /nodes/{id}/verify is the bounded server-side proof. It requires
If-Match from a prior node response, reopens the blob through the same mixed
loose/packed store used for downloads, and returns the recorded and computed
version ID, hashes, and sizes. Missing, corrupt, and unreadable content are
successful reports with verified: false and a problem. Transport,
validation, and stale-node failures remain non-2xx responses. The route checks
the revision again after reading, so a concurrent rename, trash, or content
replacement yields 412 instead of ambiguous evidence.
The single-node route is exempt from the ordinary request timeout. It is bounded in scope, not necessarily short in duration: hashing one very large blob may take longer than a minute.
These are integrity receipts from the authenticated daemon, not non-repudiable attestations against a malicious server. Signed receipts or a transparency log are outside Docbank's current trust model.
Addendum: POST /ingest, POST /ingest/stream, and POST /ingest/preflight¶
POST /ingest/preflight takes
{paths: [...], include: [...], exclude: [...]} and performs a
metadata-only source inventory. It opens no regular-file content and writes no
vault metadata or blobs. Its report includes file/directory/logical-byte totals,
pack-eligible, loose-only, and rejected size classes, exclusion/skip/error
counts, bounded findings, and extension summaries. The route uses the same
absolute-path validation, explicit root-directory-symlink behavior, exclusion
rules, loopback fence, and timeout exemption as the real ingest. Findings are
observations rather than a snapshot lock: sources can still change before
ingest, and metadata-only scanning cannot prove later content readability.
Include and exclude patterns use / separators on every platform. A backslash
in a pattern is rejected. Entries filtered by a rule are excluded without
failure, while selected non-regular entries are reported as skipped findings.
POST /ingest takes server-side local paths: {paths: [...],
dest: "/inbox", include: [...], exclude: [...], replace: false,
collection_label: "Review set"}. It returns an IngestReport (ingest_id,
added, skipped, excluded, per-path failed entries) and backs
docbank add. collection_label is optional. Paths must be
absolute: the long-lived daemon's working directory is meaningless,
so a relative path is rejected with 422. The CLI resolves
docbank add's arguments to absolute paths before calling, so the command-line
UX still accepts relative and cwd-relative sources. Collisions resolve by the
same suffixing rules as other imports when replace is false or omitted.
With replace: true, the daemon resolves the exact destination name and
records its node revision before reading source content. Changed bytes create
a new version on that node, while equal hash and size skip without changing
the stored MIME type or version history. A live directory fails before source
I/O. A stale revision or exact-name create race fails the file and never falls
back to a suffix.
POST /ingest/stream accepts the same body, including replace, and returns
application/x-ndjson. A metadata-only scan stage establishes advisory file
and byte totals, followed by ingest progress for bytes read and file outcomes.
Exactly one result carrying IngestReport or error terminates the stream.
HTTP 200 alone is not success. A write failure or client disconnect cancels the
request context used by traversal, blob writing, and metadata transactions.
Already completed files remain valid and converge on retry, while an
incomplete blob never receives node authority.
One request is one logical ingest run. Its ingest_id appears only after at
least one document observation commits. A partial import therefore returns the
run that owns its successful files alongside the individual failures. A scan,
an excluded-only request, or an all-failed request returns no ingest_id and
creates no collection or label authority. An initial label collision, or an
initial label attempted after permanent audit enrollment, is reported through
the same per-file failure protocol. Other files in the request still follow
the established partial-import behavior.
Repeating a logical filesystem import creates a new run and records the
existing same-content node as a member without creating a content version.
That new provenance advances the node revision, even when collection_label
and replace are omitted or replace: true finds identical bytes. A client
holding the previous ETag must read the node again before writing. The old
If-Match returns 412 stale_revision. Within one run, the same observation
is idempotent. The separate unkeyed POST /uploads retry contract is
unchanged: an equal retry returns the existing node without changing its
revision and does not expose an unused run identifier.
Because they grant "read any daemon-readable local path," POST /ingest and
POST /ingest/stream are checked per-request against RemoteAddr and
restricted to loopback callers regardless of bind address or API key. A
non-loopback client gets 403 (loopback_only). There is no remote file-upload
capability on this route: remote bytes use POST /uploads, while remote access
to the loopback-bound daemon still terminates through the configured SSH/VPN
tunnel.
Each include or exclude pattern uses Go's path.Match grammar over a
slash-separated source-relative path. Use / separators on every platform.
Backslashes are rejected. A pattern without / matches a basename at any
depth. A pattern containing / matches the relative path. * and ?
do not cross /, and ** has no recursive globstar behavior. An include filters
regular files only, while a matching exclusion wins and prunes a directory's
subtree. The preflight and ingest implementations share this compiler so
reviewed selection and actual selection cannot drift. Patterns must be relative.
Invalid syntax and parent traversal are rejected before filesystem access. Use
bracket expressions such as report[[]1].txt for literal metacharacters instead
of backslash escaping. Watched-inbox exclusions are a separate literal contract.
Addendum: ingest-run collections¶
GET /api/v1/collections/{id}/quality returns a current aggregate receipt with
the collection, source_fingerprint, dimensions, zero-byte and mismatch counts,
duplicate-document counts, and descriptive concentrations. Optional fields
accepts at most seven distinct comma-separated names: extension, media_type,
media_family, modified_month, size, text_coverage, and duplicates.
The default includes all seven. Unknown or repeated fields return 422.
Only quality accepts profile=<configured name> and includes
collection.coverage. Ordinary list, detail, and members reads do not inspect
processing state. One profile is selected automatically. No profiles
returns unconfigured, and multiple without a choice returns profile_required.
These unavailable states have null counts. An unknown name returns 422.
Configured coverage includes the selected name and fingerprint, active generation,
and complete, partial, failed, unprocessed, and none counts over current members.
Selection identifies policy, not runtime readiness. Retained active output wins
over a later failed attempt. Source hashes alone cannot transfer profile authority.
Quality uses one source snapshot, bounded to 250,000 members, 64 MiB of projected
census data, and five seconds. Size bounds return 413 quality_too_large.
Interruption or timeout returns 503 quality_unavailable, not partial results.
Frequency dimensions retain the top 50 values with missing and other counts.
Duplicate membership is vault-wide current content, counted within this collection.
Concentrations require at least ten members and 80% of the collection.
Each request reads current source data. Extension and MIME buckets follow the
query operand rules. Values that cannot name a concrete operand count as missing.
MIME parameters do not affect the bucket, matching the MIME search predicate.
A collection is an immutable ingest-run identity with live document
membership. It is not a folder: moving or renaming a member leaves its run
identity intact. Membership excludes caller-supplied embedded: provenance,
superseded provenance, directories, and trashed files. Counts and byte totals
deduplicate nodes within a run and describe each member's current version.
They are browsing observations rather than an export snapshot.
Lists default to 100 results and accept limit=1..1000 and a nonnegative
offset. They are ordered by started_at descending, then ingest ID. Member
pages use the same bounds and return current Node representations with live
paths. The list hides empty runs. A directly addressed run remains readable
when it has retained label authority, even after its final member is trashed or
purged.
$ curl -sS -H "X-Api-Key: $DOCBANK_API_KEY" \
'http://127.0.0.1:43210/api/v1/collections?limit=100&offset=0'
{"items":[{"id":"5ca58787-4608-4f69-8e67-8d5794970f77","source_kind":"cli","source_description":"/srv/import/review","started_at":"2026-09-10T09:30:00Z","file_count":2,"total_bytes":31,"label":"Review set","label_revision":1,"label_updated_at":"2026-09-10T09:30:00Z"}],"total":1,"limit":100,"offset":0}
$ curl -sS -H "X-Api-Key: $DOCBANK_API_KEY" \
'http://127.0.0.1:43210/api/v1/collections/5ca58787-4608-4f69-8e67-8d5794970f77/members?limit=100&offset=0'
Request only extension counts when that is all you need. Coverage still describes all current members; without a processing profile, its counts are null.
$ curl -sS -H "X-Api-Key: $DOCBANK_API_KEY" \
'http://127.0.0.1:43210/api/v1/collections/5ca58787-4608-4f69-8e67-8d5794970f77/quality?fields=extension'
{
"collection": {
"id": "5ca58787-4608-4f69-8e67-8d5794970f77",
"source_kind": "cli", "source_description": "/srv/import/review",
"started_at": "2026-09-10T09:30:00Z", "file_count": 2, "total_bytes": 31,
"label": "Review set", "label_revision": 1, "label_updated_at": "2026-09-10T09:30:00Z",
"coverage": {
"configuration": "unconfigured", "profile": "", "profiles": [],
"profile_fingerprint": "", "generation_id": "", "counts": null
}
},
"source_fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"dimensions": [{"field": "extension", "values": [{"value": "txt", "count": 2}], "missing": 0, "other": 0}],
"zero_bytes": 0, "mismatches": 0, "duplicate_documents": 0, "spikes": []
}
Labels have their own resource and revision. Read that resource for its ETag,
then send the quoted positive revision in If-Match. The PUT body contains
exactly one required label member. A string sets the label; explicit null
clears it. Unknown fields and server-owned fields such as revision are
rejected.
$ curl -i -H "X-Api-Key: $DOCBANK_API_KEY" \
http://127.0.0.1:43210/api/v1/collections/5ca58787-4608-4f69-8e67-8d5794970f77/label
HTTP/1.1 200 OK
ETag: "1"
{"ingest_id":"5ca58787-4608-4f69-8e67-8d5794970f77","label":"Review set","revision":1,"updated_at":"2026-09-10T09:30:00Z"}
$ curl -sS -X PUT -H "X-Api-Key: $DOCBANK_API_KEY" \
-H 'Content-Type: application/json' -H 'If-Match: "1"' \
-d '{"label":"Filed set"}' \
http://127.0.0.1:43210/api/v1/collections/5ca58787-4608-4f69-8e67-8d5794970f77/label
$ curl -sS -X PUT -H "X-Api-Key: $DOCBANK_API_KEY" \
-H 'Content-Type: application/json' -H 'If-Match: "2"' \
-d '{"label":null}' \
http://127.0.0.1:43210/api/v1/collections/5ca58787-4608-4f69-8e67-8d5794970f77/label
Labels are case-sensitive NFC strings of 1–256 UTF-8 bytes, with no control
characters and at least one non-whitespace character. Intentional surrounding
spaces are preserved. Non-null labels are unique across all retained label
records, including empty collections, so trash and restore cannot transfer a
name between runs. Clearing retains the revision fence. A stale revision
returns 412 stale_revision. A collision returns 409 exists. An invalid
name returns 422 invalid_collection_label.
Permanent audit authority currently records ingest and membership changes but
has no event for label mutation. Once audit is enabled, label PUTs therefore
fail closed with 409 audit_mutation_unsupported, including clears and no-op
requests. Existing labels remain readable and portable. Backup readers that
predate collection labels or the ingest_observe audit kind reject that added
authority instead of silently dropping it.
Addendum: POST /uploads¶
POST /uploads?parent_id=<id>&name=<filename> accepts exactly one
multipart/form-data file field named file. The query uses a stable
destination directory ID rather than a mutable path. The multipart filename
must equal the normalized name query value, preventing the envelope and
requested tree entry from describing different files.
Two request headers declare the expected identity of the file part, not the multipart envelope:
X-Docbank-Blob-Hash: canonical lowercase hexadecimal SHA-256.X-Docbank-Blob-Size: raw byte length, at most Docbank's 64 TiB logical-object admission ceiling.
The server streams the file once through Kit's durable writer and independently
computes both values. One metadata transaction then grants blob authority and
creates the node, but only after the values match, the closing multipart
boundary has been validated, and no extra parts remain. 201 with
status: "added" identifies a new node and its initial content_create
version. Repeating the same name, hash, and parent converges to that stable
node with 200 and status: "skipped". The receipt always includes the
server's computed_hash, computed_size, and the node's ID and revision.
Clients compare the values themselves.
Uploads are file-granular. A caller sending many files issues independent requests (concurrently when useful), so one failure never makes the success of another file ambiguous and each item can be retried on its own. Different content under the same requested name follows normal ingest suffixing rather than overwriting an existing document.
Objects through 64 MiB are eligible for packing. Larger accepted objects remain loose, but use the same content-hash authority, verified streaming, backup, and restore contracts. The 64 TiB admission limit therefore does not imply that a single object will be moved into a pack.
Digest or size disagreement returns 422 digest_mismatch or
422 size_mismatch and grants no new blobs row or node authority. Because
physical bytes are published before metadata by design, a rejected stream may
leave an authority-free loose object. The normal GC untracked-file scan removes
it. Malformed envelopes and extra parts are also rejected before authority.
Request bodies are capped at the declared size plus bounded multipart overhead.
The route is exempt from the ordinary timeout, so a large upload is governed
by client cancellation rather than a one-minute deadline.
Addendum: PUT /nodes/{id}/content¶
Content replacement accepts raw bytes rather than multipart. A caller first
reads the file node and sends its revision in If-Match, then declares the raw
body's canonical SHA-256 and byte count in X-Docbank-Blob-Hash and
X-Docbank-Blob-Size. Content-Type is normalized and stored on the new
version. An omitted value becomes application/octet-stream.
The daemon streams the body into durable authority-free storage and computes
the identity independently. Only exact agreement permits one metadata
transaction to create a content_replace version, advance the node's current
pointer, and bump its revision. The old head remains an immutable history and
GC root until the operator prunes that version. A successful response includes
the resulting ETag plus a receipt containing the node, new version,
computed_hash, and computed_size. Clients compare every field with the
request before accepting success.
Clients should send Expect: 100-continue for large writes. The daemon checks
the target kind and revision before its first body read, then repeats those
checks in the committing metadata transaction. The early check avoids wasting
bandwidth. Only the transactional check grants authority.
Stale revisions return 412 stale_revision. Missing preconditions return
428 precondition_required. Identity disagreements return
422 digest_mismatch or 422 size_mismatch. A failed operation grants no new
catalog authority, although a completely written loose object can remain
authority-free until GC. Because the body may be binary, this route is an
exception to the raw JSON text validator. Its byte count and digest are the
lossless boundary instead. Cancellation propagates through physical writing
and prevents the metadata transaction.
Addendum: POST /nodes/{id}/revert¶
Reversion accepts JSON {"source_version_id":"<uuid>"} and requires the
target node's revision in If-Match. The source must be an immutable version of
that same node and cannot be its current version. A successful transaction
creates a distinct content_revert row, copies the source's blob hash, size,
and media type into it, records source_version_id, advances the current
pointer, and bumps the node revision.
This operation is metadata-only: it neither streams nor copies the source blob,
whether loose or packed. Every existing version remains a reachability root
until version pruning selects it.
The receipt contains node, the new version, and source_version, while the
ETag carries the resulting revision. Clients cross-check all four authorities.
HTTP 200 alone is not sufficient evidence.
A stale target returns 412 stale_revision. A source from another node returns
422 version_node_mismatch, selecting the current head returns
422 version_already_current, and an unknown source returns 404 not_found.
Addendum: POST /nodes/{id}/versions/prune¶
Version pruning releases selected non-current history without changing current
content. Every request requires the inspected node revision in If-Match and
chooses exactly one selector: version_ids (at most 1,000 canonical UUIDs),
keep_newest, older_than, or all_prior. The default is a dry run.
"run":true performs the reported class of operation under the same node
revision precondition.
Ordinary selectors retain revert-source dependencies and report them
separately. all_prior may first install a same-byte, source-free checkpoint
when the current head is a revert, allowing the complete older graph to be
removed safely. A successful run advances the node revision once when it
deletes history and does not advance it for an empty selection. Deleted version
IDs stop resolving.
An older_than selector computes and returns its cutoff for each request. The
node ETag protects content-graph changes, but wall-clock aging does not advance
the revision. A later run can therefore include versions that crossed the age
boundary after a preview. Callers needing an exact replay execute the preview's
candidate IDs through version_ids.
The receipt separates logical history bytes from physical consequences. Shared
blobs remain reachable, authority-free loose blobs await GC, and dead packed
payload awaits GC followed by repack. Loose and packed counts may overlap when
one blob has both representations across stores. The receipt reports that
intersection as mixed_blobs_pending_maintenance, and its physical byte totals
cover all affected authoritative locations. Pruning itself does not claim
physical space reclamation.
Addendum: tags¶
Tag definitions use stable server-generated UUIDv4 identities, mutable,
unique NFC-normalized names, and a revision covering both the definition and
its assignment set. POST /tags, GET /tags, GET /tags/by-name,
and GET|PATCH|DELETE /tags/{tag_id} expose definition lifecycle.
GET /nodes/{id}/tags and GET /tags/{tag_id}/nodes provide bounded forward
and reverse listings. Reverse results include a path only for live nodes.
live_only=true returns a bounded live projection and omitted_trashed count
from one metadata snapshot, which is suitable for an interactive live browser
without assembling paths and trash states across pages.
PUT|DELETE /nodes/{id}/tags/{tag_id} assign and unassign under the required
node If-Match revision. Their receipt contains the resulting node and tag,
changed, and the resulting node ETag. Repeating the requested state returns
changed: false without advancing either revision. A real assignment change
advances the node and tag once. Single-tag definition responses carry the tag
ETag. PATCH|DELETE /tags/{tag_id} require that ETag in If-Match. Renaming
advances the tag and every assigned node. Deleting checks the current tag
revision before removing the complete assignment set and advancing each
assigned node. Deletion never removes nodes or document bytes.
Path-oriented clients use PUT|DELETE /path/tags/{tag_id} with {"path":"/..."}.
The store resolves that live path and changes the assignment in one SQLite
transaction. This is stronger than a separate path lookup followed by the
ID-addressed endpoint: moving an ancestor changes a descendant's path without
changing that descendant's revision.
Ingest provenance is filesystem-shaped: each import records the source's
original path and mtime in the store's provenance table. Authenticated clients
can also append a post-ingest fact with a source kind, description, opaque
original path, optional mtime, and optional supersession, checked against the
node's If-Match revision. The append records evidence and never opens the
supplied path or looks up a node by content hash.
Maintenance gate¶
gc --run, trash empty, and verify need the vault quiescent while
they run because of the reachability-then-delete race described in
Ownership and concurrency. The daemon's exclusive vault lock is
held for the daemon's whole lifetime, not per request, so an in-process
sync.RWMutex-shaped gate serializes them against regular mutations.
Ordinary mutating handlers (PATCH, trash, restore, create, ingest) take the
read side and may run concurrently with each other. Maintenance handlers take
the write side. Once maintenance is running or queued for that write side, a
new HTTP mutation fails immediately with 503 maintenance_busy instead of
becoming an indistinguishable long wait.
Daemon-owned background jobs keep blocking gate semantics so a transient
maintenance run does not permanently fail a watcher or extraction worker.
Maintenance routes are exempt from the per-request timeout since
gc/verify can run long on a large vault.
Backup creation uses the mutation-exclusive side only for Kit's freeze window. Once Docbank's deferred read transaction is pinned, ordinary mutations continue into SQLite's WAL while verified metadata and blob streams are captured. The backup holds a separate shared preservation lease until capture ends. Maintenance takes that lease exclusively, so GC cannot delete a loose blob or catalog mapping still referenced by the pinned snapshot. The create route is timeout-exempt. Cancellation still propagates through Kit and prevents publication of a snapshot manifest.
MCP is a daemon client¶
docbank mcp is a separate process and listener, not another path into the
vault. It uses the same daemon discovery and authenticated HTTP client as the
CLI, so the daemon remains the only standalone owner of SQLite, blob, pack,
rendition, processing, and lock authority. There is no direct-vault or
remote-daemon mode behind MCP.
The MCP boundary implements exactly protocol 2026-07-28 through the official
Go SDK v1.7.0. Stdio is newline framed. Its optional HTTP transport is
stateless, POST-only, loopback-only, and separately authenticated. The MCP
bearer is resolved from a named credential binding and cannot equal the
daemon's configured, ephemeral, or runtime-discovered API key. The daemon API
key is never accepted as an inbound MCP credential.
MCP exposes ten bounded read tools, including photo inspection, plus optional processing enqueue and opt-in photo mutation tools. The enqueue preserves the daemon's existing consent and plan-fingerprint checks. It cannot grant consent or replay an ambiguous start. Rendition resources bind the stable vault, node, content-version, and attachment tuple and expose only bounded windows of active sanitized Markdown. They do not expose source bytes or host paths.
This fixed local HTTP bearer is not MCP OAuth. The listener has no OAuth metadata, authorization-server discovery, client registration, scopes, or refresh. It also has no GET streams, sessions, resumption, prompts, roots, sampling, elicitation, or tasks. See Model Context Protocol for the complete catalog and limits.
Auth¶
Send the key as X-Api-Key or Authorization: Bearer <key>. The daemon
compares it in constant time against its effective key. The daemon always has
one: with [server] api_key unset it generates a fresh key at startup and
publishes it, inside the owner-private $DOCBANK_HOME, through the same runtime
record the CLI already uses for discovery. It is readable only by the
vault's owner, never sent over the network unencrypted, and never logged.
Binds are loopback-only: the API is plain HTTP, so a non-loopback bind
would expose the key and vault contents in cleartext, and
docbank daemon run refuses to start on one. Remote access goes through an SSH
tunnel or VPN (see Configuration). /health,
/api/ping, /docs, the OpenAPI documents, and the static web application at
/, /photos, and /assets/ are auth-exempt. Everything else, including the shutdown
route, requires the key.
Error mapping¶
Errors are RFC 7807 problem-JSON with a code extension, a machine-readable
string clients branch on instead of parsing detail. Query errors may also
include a position span:
{
"title": "Conflict",
"status": 409,
"detail": "node \"report.pdf\" already exists",
"code": "exists"
}
code |
HTTP | Source |
|---|---|---|
not_found |
404 | store.ErrNotFound |
exists |
409 | store.ErrExists (name collision) |
cycle |
409 | store.ErrCycle (move under own descendant) |
audit_mutation_unsupported |
409 | the audited vault does not yet record this logical mutation class |
audit_already_enabled |
409 | an initial-scope preview cannot execute because audit authority was enabled concurrently |
audit_scope_overlap |
409 | the proposed scope shares a live or retained-trash member with permanent protection |
audit_scope_limit |
409 | the vault already has the maximum 1,000 permanent scopes representable by terminal evidence |
audit_preview_stale |
409 | the one-use enrollment preview expired, was consumed, came from another daemon, or no longer matches the vault |
audit_acknowledgment_required |
422 | enrollment execution omitted the permanent-retention acknowledgment |
audit_not_enrolled |
422 | the selected node exists but is outside every permanent audit scope |
invalid_audit_cursor |
422 | the history cursor is malformed or belongs to another stable node or scope |
invalid_batch_move |
422 | a batch has no moves, too many moves, ambiguous selectors, or an invalid final-state plan |
invalid_collection_label |
422 | a collection label violates the canonical UTF-8, NFC, length, control-character, or non-whitespace policy |
stale_revision |
412 | store.ErrStaleRevision: If-Match didn't match the current revision |
provenance_mismatch |
409 | the requested predecessor is missing, belongs to another node, is already superseded, or is an operational ingest fact |
invalid_provenance_time |
422 | optional original_mtime parses as RFC3339 but is not canonical UTC RFC3339Nano (a value that is not a date-time at all fails schema validation as validation instead) |
invalid_saved_query |
422 | saved name, description, kind, payload, or patch violates the saved-definition contract |
invalid_photo_asset |
422 | asset kind, role, display pointer, sidecar target, or graph state is invalid |
invalid_photo_album |
422 | album name, member cover, explicit asset selection, or mutually exclusive selection fields are invalid |
photo_node_not_eligible |
422 | the selected node is not a live eligible file |
photo_node_owned |
409 | the selected node already belongs to another photo asset |
invalid_query |
422 | invalid or unsupported expression, missing reference, or query compilation bound exceeded |
invalid_cursor |
400 | malformed, tampered, wrong-direction, or otherwise invalid snapshot cursor |
snapshot_gone |
410 | snapshot is missing, expired, revoked, owned by another session, or lost with its daemon |
snapshot_capacity |
429 | bounded snapshot cache admission could not reserve the requested rows or serialized bytes |
snapshot_busy |
429 | both bounded snapshot builders are already occupied |
snapshot_too_large |
413 | one materialization exceeded its row, per-row, or serialized-size bound |
snapshot_unavailable |
503 | snapshot materialization exceeded its build deadline |
invalid_profile |
422 | requested processing profile or coverage selection is invalid |
invalid_saved_query_run |
422 | saved-run identity, revision, or execution request is invalid |
not_dir / not_file / invalid_name / invalid_tag / not_trashed / is_root |
422 | store.ErrNotDir / ErrNotFile / ErrInvalidName / ErrInvalidTag / ErrNotTrashed / ErrIsRoot |
search_query_required |
422 | blank search without a tag or modification-time filter |
validation |
400, 415, or 422 | malformed request (bad If-Match, paths, media type, multipart envelope, or generated validation) |
precondition_required |
428 | required If-Match header missing |
loopback_only |
403 | server-path ingest or preflight called by a non-loopback peer |
digest_mismatch / size_mismatch |
422 | uploaded file bytes disagree with the required declaration; no node/blob authority committed |
too_large |
413 | upload exceeded its declared size plus bounded multipart overhead |
maintenance_busy |
503 | exclusive vault maintenance is running or queued; retry the mutation after it finishes |
pack_retirement_deferred |
503 | repack authority committed but an old source pack remains physically locked; release the lock, then run storage pack reconciliation |
unauthorized |
401 | missing or invalid API key; bad shutdown token |
web_session_read_only |
403 | a daemon-issued browser session attempted an endpoint outside its attenuated allowlist |
web_unavailable |
503 | this daemon is not serving compiled web assets |
internal |
500 | unmapped error (still surfaced with a message, because this is a single-user local daemon, not a hardened multi-tenant service) |
Non-goals¶
- No server-side rendering. The kit-ui application is static public code. It
receives a daemon-lifetime attenuated session from
docbank web. Reads, verified-download preparation, and digest-checked file upload remain ordinary authenticated API requests. The master API key never enters the browser. - No multi-user model: one vault and one master authority. Browser sessions are attenuated local capabilities, not accounts. Sharing is out of scope for v1.
- No MCP endpoint inside
docbank daemon run.docbank mcpremains a bounded client process with its own transport and credential boundary. - No remote-daemon mode or
[remote]configuration.