Documents API
Submit the source text your agents read, fetch what happened to a submission, turn a citation back into the passage it points at, and erase a document so every copy of it becomes unreadable at once.
Use the Documents API to register the text your agents learn from, so a claim or a learning can point at the exact passage it came from rather than at a filename you have to go and find. A document is stored once and cited many times: the passage lives on the document, never beside the claim, which is what lets an erasure remove the quote everywhere in one operation.
Every route here is scoped to one agent, and the document key is yours. You choose it, we keep it exactly as you sent it, and you use it again to fetch, cite or erase.
Access control
There is no separate documents scope. Reads (GET, POST /citations:resolve) need agents:read, writes (PUT, DELETE) need agents:write, and the caller must additionally hold the matching relationship on that agent. See Access control (FGA and RBAC).
Your tenant needs a document key before any of this works
Documents are encrypted under a wrapping key held for your tenant, and a tenant with no key binding cannot legally hold a document at all. Until one is bound, PUT answers 409 and nothing is queued. Binding is an operator action on our side, not something you can do through the API, so if you get a 409 with no obvious cause, that is what it means: ask us to bind a key for your tenant.
This endpoint never creates an agent
Unlike the learning boundary, which mints an agent from agent_name on first use, every route here answers 404 for an unknown agent. Create it with POST /agents first. The reason is custody rather than consistency: minting the agent would mint a key binding nobody chose.
Choosing a document key
doc_id is your own identifier, and it may contain slashes: policies/refunds.md is what a real key looks like, and the repository content ingest mints {owner}/{repo}:{path}, one file to one document.
It is checked for shape and returned verbatim. It is never trimmed, collapsed or normalised, because the key is your identity for the document, and normalising it would silently make two of your documents one. Two keys differing only by whitespace are two different documents.
A key is refused with a 400 when it is empty or only whitespace, is longer than 512 bytes of UTF-8 (bytes, not characters, so a multibyte key is shorter than it looks), carries a control character, carries a backslash, starts with /, has an empty path segment, or has a . or .. segment. Those last five each land the bytes somewhere other than the document's own prefix on a store that resolves its keys as paths.
Submit a document version
PUT /agents/{agent_id}/documents/{doc_id} stores one submission and queues it for ingest.
The body is the raw document, not JSON. Two optional headers describe it.
curl -X PUT \
-H "Authorization: Bearer <API_KEY>" \
-H "X-Document-Filename: refunds.md" \
-H "X-Document-Type: markdown" \
--data-binary @refunds.md \
"https://api.hyperstruck.com/agents/<AGENT_ID>/documents/policies/refunds.md"Prop
Type
Required scope: agents:write
The response carries doc_id, version, state, enqueued and job_id. version is the sha256 of the bytes you sent, which is also the version's identity.
202 and 200 mean different things, deliberately
A 202 means the submission was enqueued and enqueued is true. A 200 means these exact bytes were already at head, so nothing was queued and enqueued is false. A 202 on a duplicate would promise work that will never happen, and a client retrying after a timeout would believe it. Re-sending a document you already sent is safe and cheap; it is not an error, and you get the existing version back.
Refusals happen at the door, before anything is enqueued, so a rejected submission never burns a queue slot:
| Status | Why |
|---|---|
400 | The document key is not a shape that names a document that can exist. See above. |
404 | No such agent. Create it with POST /agents first. |
409 | Your tenant has no document key binding, so no write here can be legal yet. |
413 | Above the configured size ceiling, which is what the ingest worker can finish inside its timeout. The default is 25 MiB and it is a deployment setting, so read the number in the response body rather than assuming this one. |
503 | The document runtime is not configured or is unavailable on this deployment. |
Concurrent submissions of one doc_id are safe without any coordination on your side. Writers and erasures of a single document serialise, and two callers sending identical bytes both receive the same version rather than one of them receiving an error for something neither did wrong.
Say which account the document is about
The form above stores the bytes and nothing else. To make a document reachable by an account, send it as multipart/form-data with a content part and a metadata part instead.
This is the step nothing else can do for you. Every other way of working out which account a document concerns has to read the prose, so a helpdesk ticket called ticket-8837.json whose contents are about a workbook reaches no account at all. Your connector already knows which account it is; record_context is where you say so. A document submitted without one is stored and ingested perfectly well, and then Answer API will not find it when somebody asks about that account.
curl -X PUT \
-H "Authorization: Bearer <API_KEY>" \
-F "content=@review-2026-09-15.md;type=text/plain" \
-F 'metadata={"title": "Meridian Books quarterly account review, September",
"occurred_at": "2026-09-15T02:00:00Z",
"record_context": {
"concerns": [
{"scheme": "crm:account", "value": "meridian-books", "name": "Meridian Books"}
]
}};type=application/json' \
"https://api.hyperstruck.com/agents/<AGENT_ID>/documents/crm/meridian-books/review-2026-09-15"Prop
Type
Each entry in record_context.concerns is one container the document belongs to:
Prop
Type
Send the whole context every time
record_context is a declaration, not a patch. It replaces what you sent with the document last time, so a participant dropped from a corrected invite and a container moved to another account are both retracted by the write itself. Send the complete context on each submission rather than only what changed.
Metadata is not applied to bytes that are already stored
Metadata travels with a version. Re-sending identical bytes returns 200 with enqueued: false and does not apply the new metadata, because nothing is re-read. If you need to correct the context on a document already at head, change the document so the bytes differ, or submit it under a new key.
Fetch a document and its versions
GET /agents/{agent_id}/documents/{doc_id} returns the document, every raw version, and two separate state fields.
curl -H "Authorization: Bearer <API_KEY>" \
"https://api.hyperstruck.com/agents/<AGENT_ID>/documents/policies/refunds.md"Required scope: agents:read
Prop
Type
Do not collapse the two state fields into one status
ingest is the queue's view and version_state is the document's own. They are never conflated, because superseded is a version state that means the document is citable and has simply been amended. It is not a failure, and a client polling for completion that treats it as one will report a healthy document as broken. Poll ingest for "is the work done"; read version_state for "what is this version".
A doc_id with neither a job nor a version is a 404, so a typo is distinguishable from a submission still sitting in the queue.
An ordinary re-upload has relation of amends, which retires nothing: the earlier version stays citable, and a citation minted against it keeps resolving.
Resolve a citation
POST /agents/{agent_id}/documents/citations:resolve turns a six-member citation back into the text it points at.
curl -X POST \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"citation": {
"doc_id": "policies/refunds.md",
"text_sha256": "<VERSION_SHA256>",
"segmenter_version": "<SEGMENTER_VERSION>",
"unit_index": 12,
"start": 0,
"end": 184
}
}' \
"https://api.hyperstruck.com/agents/<AGENT_ID>/documents/citations:resolve"Required scope: agents:read
All six members are required. Five of six is a span with no end, or an end with no document, and resolving one would mean inventing the missing member. unit_index, start and end are integers, not numeric strings: a numeric string is a second shape for the same pointer and is refused.
The citation is in the body rather than the path because a document key may contain slashes, so /{doc_id}/citations:resolve could never be told apart from a document whose key ends in citations:resolve. The POST verb is what disambiguates this route, which means a document actually named citations:resolve is still fetched, written and erased correctly.
Four outcomes, kept distinct because you act on each differently:
outcome | What it means, and what to read |
|---|---|
resolved | The passage is in quote. |
truncated | The stored excerpt is bounded and the span reaches past it. retained_quote carries the part still held and retained_end_char says where it stops, rather than silently shortening the answer. |
erased | The document was erased, and doc_id names it. This is a real answer, not a failure to find one. |
missing | Nothing resolved, with reason saying why. |
erased and missing are not the same answer
Flattening these four into a nullable quote loses the difference between "we deleted this" and "this never existed", at exactly the moment a customer is asking you about a deletion. Show the two differently.
Erase a document
DELETE /agents/{agent_id}/documents/{doc_id} destroys the wrapped data keys first, so from that instant every copy they protect is unreadable whether or not the deletes that follow complete. It then returns a receipt naming what each scope actually reached.
curl -X DELETE \
-H "Authorization: Bearer <API_KEY>" \
"https://api.hyperstruck.com/agents/<AGENT_ID>/documents/policies/refunds.md"Required scope: agents:write
Prop
Type
Any queued or running ingest for the document is cancelled inside the same transaction that authorised the erasure. A deletion request is the one operation that must never queue behind other work, and cancelling first means an in-flight ingest cannot write new encrypted units under a key that is about to be destroyed.
Read is_complete before reporting the erasure
is_complete false does not mean the data is readable: the keys are gone either way. It means one scope could not report what it reached, so the receipt is honest about the gap instead of showing you a zero you would read as success. Keep the receipt; it is the evidence that the erasure happened.
After an erasure, citations pointing at that document resolve with outcome erased rather than failing, and any resolved passage stops being returned. This is the whole reason the passage is stored once on the document rather than copied beside each claim: there is no second copy to outlive the deletion.
Related
- Answer API for the read that consumes what you declared here: a question about an account is answered from the documents whose
concernsname it. - Claims for what a claim is and how a cited value differs from an asserted one.
- Claims API for reviewing and governing what your agents believe.
- Distill documents for extracting learnings from a corpus, which is a different operation: distill reads a corpus and returns learnings, while this API registers text so it can be cited.