curl --request POST \
--url https://app.sapientpriors.com/api/v1/agents/{agent_id}/threads/{thread_id}/observe-file \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: multipart/form-data' \
--form file=@example-fileimport requests
url = "https://app.sapientpriors.com/api/v1/agents/{agent_id}/threads/{thread_id}/observe-file"
files = { "file": ("example-file", open("example-file", "rb")) }
headers = {"Authorization": "Bearer <token>"}
response = requests.post(url, files=files, headers=headers)
print(response.text)const form = new FormData();
form.append('file', '<string>');
const options = {method: 'POST', headers: {Authorization: 'Bearer <token>'}};
options.body = form;
fetch('https://app.sapientpriors.com/api/v1/agents/{agent_id}/threads/{thread_id}/observe-file', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));{
"agent_id": "agent_01JZ8X9K6AS3V8SGP0Q9A2P4FH",
"thread_id": "thread_01JZ8XASKN4R1HXV4X19A3T02J",
"observation_id": "obs_01JZ8XQ8JQZNQDRG6EB7N1M3PV",
"accepted": true,
"turns_total": 24,
"filename": "q3-report.pdf",
"doc_format": "pdf"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}Ingest a document as observed turns
An IMAGE uploaded here is read, not merely looked at: it is sent to the document reader as a one-page document, so a photographed receipt or a screenshot yields its full text, chunked and indexed like any other document. That is different from an image attached to a chat message, which is shown to the model as pixels for the current answer and is not ingested. Images must carry a real PNG/JPEG/GIF/WebP signature; the extension alone is never enough. SVG is not accepted.
Converts an uploaded document to Markdown, splits it into turn-sized pieces, and ingests each piece as exactly one observed turn on this thread. Conversion and splitting happen in-request, so turns_total is exact in the 202 and a document that cannot be converted fails synchronously rather than via polling. Only ingestion is asynchronous; poll the observation status endpoint, whose turns_processed / turns_total IS document progress because one piece is one turn.
Accepted file types: documents (.pdf, .doc, .docm, .docx, .ppt, .pptm, .pptx, .pot, .pps, .ppsm, .ppsx, .xls, .xlsb, .xlsm, .xlsx, .odt, .ods, .odp, .rtf, .epub, .csv), images (.png, .jpg, .jpeg, .gif, .webp), and plain text and source files, read as they are: .txt, .md, .markdown, .rst, .log, .json, .jsonl, .yaml, .yml, .toml, .xml, .html, .sql, .tsv, the common code extensions (.py, .js, .ts, .go, .java, .c, .cpp, .rs, and more), and files named Dockerfile or Makefile. The type is detected from the file’s content first and its extension second, so the part’s declared Content-Type is not trusted. Text has no signature, so for text the extension decides. Up to 10 MB.
Error codes on 400: unsupported_document (the type is not one we convert), document_needs_ocr (a known type carrying no extractable text, typically a scanned PDF), encrypted_document (password-protected), malformed_document (truncated or structurally unusable), empty_document (converted to no text at all), invalid_request (a chunking override outside its allowed range). On 413: document_too_large (over the byte limit), document_too_complex (a converter safety limit was crossed), document_too_many_turns (more than 2,000 pieces).
Ordering within the document is preserved. When this agent is already reading another file, the upload is accepted with queued: true and starts by itself when the one ahead finishes.
curl --request POST \
--url https://app.sapientpriors.com/api/v1/agents/{agent_id}/threads/{thread_id}/observe-file \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: multipart/form-data' \
--form file=@example-fileimport requests
url = "https://app.sapientpriors.com/api/v1/agents/{agent_id}/threads/{thread_id}/observe-file"
files = { "file": ("example-file", open("example-file", "rb")) }
headers = {"Authorization": "Bearer <token>"}
response = requests.post(url, files=files, headers=headers)
print(response.text)const form = new FormData();
form.append('file', '<string>');
const options = {method: 'POST', headers: {Authorization: 'Bearer <token>'}};
options.body = form;
fetch('https://app.sapientpriors.com/api/v1/agents/{agent_id}/threads/{thread_id}/observe-file', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));{
"agent_id": "agent_01JZ8X9K6AS3V8SGP0Q9A2P4FH",
"thread_id": "thread_01JZ8XASKN4R1HXV4X19A3T02J",
"observation_id": "obs_01JZ8XQ8JQZNQDRG6EB7N1M3PV",
"accepted": true,
"turns_total": 24,
"filename": "q3-report.pdf",
"doc_format": "pdf"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}{
"error": "<string>",
"code": "<string>",
"request_id": "<string>",
"details": "<string>"
}Authorizations
Your API key, shaped sp_<key_id>_<secret>, sent as Authorization: Bearer sp_.... It identifies your organization, not a person. Your organization owns the agents, holds the credit, and is billed. Every key your organization holds reaches the same things, so you rotate a key by minting the new one, deploying it, and revoking the old one.
Keep the key on your server. A request that carries a Sec-Fetch-Site header is refused with 400, because only a browser sets that header. Priors sends no CORS headers, so a browser blocks the call before it leaves the page. The secret is shown once when the key is minted and is stored only as a hash. A lost secret is replaced, never recovered.
An administrator of your organization mints and revokes keys in the Priors application, under Settings. There is no endpoint for it: a key reaches your whole organization, so issuing one is an act of administration.
Headers
Makes ONE intended upload safe to retry. Send the same key on every retry of the same upload, and a new key when the user deliberately uploads again. A retry replays the original 202 verbatim without re-reading or re-billing the document; a retry sent while the first is still running answers 409; and reusing a key for a materially different request (different bytes, or different chunking) answers 409 rather than replaying a result for a question you did not ask. Keys are retained for 24 hours.
255Your own identifier for the person this request acts for: an account number, a row id, anything stable on your side. It works only with a partner API key. The value is not stored. Priors hashes it together with your organization id, so two clients that send the same string are still two different people.
With the header, you reach the threads that same person created and no others. A new thread is recorded against that person, and GET /v1/agents/{agent_id} returns that person's conversations only. Without the header, the key acts as the organization and reaches every thread on every agent it owns. Learned memory is shared across the agent either way. Only the thread list narrows.
Sent with anything that is not a partner API key, the header returns 400 instead of being ignored. Sent empty or as whitespace, it also returns 400, and it is never read as absent: absent means the whole organization, which is wider access than you asked for. Surrounding whitespace is removed. The 255-character limit is measured on the value as sent.
1 - 255\SPath Parameters
Unique managed memory-agent identifier.
32^agent_[0-9A-HJKMNP-TV-Z]{26}$Unique thread identifier scoped to the agent.
33^thread_[0-9A-HJKMNP-TV-Z]{26}$Body
The document to ingest.
Read the file again even when this agent has already read these exact bytes in another thread. Leave it out and that upload is refused with 409 document_already_ingested, because one memory index serves every thread of an agent: what the first read learned is already searchable from this conversation. Reading it again stores a second copy of every memory, which costs more and puts duplicates in competition during retrieval. Send true only when you mean it.
Ceiling on the character length of each turn. Defaults to 2000, which is set by the memory writer's budget rather than by transport.
200 <= x <= 4000Characters of the previous piece repeated at the start of the next, so a fact split across a boundary survives. Defaults to 200, and must be under half of max_chars.
x >= 0Where to cut. auto always selects the bounded lossless length strategy; source-aware code chunking is explicit opt-in only. length gives uniform sizes, sentence keeps whole sentences, code preserves definitions, and structure follows Markdown sections. Defaults to auto.
auto, length, sentence, code, structure Response
Document converted and split; its turns accepted for ingestion
Server-generated agent identifier.
^agent_[0-9A-HJKMNP-TV-Z]{26}$Server-generated thread identifier.
^thread_[0-9A-HJKMNP-TV-Z]{26}$Server-issued identifier for the asynchronous ingestion. Pass it to the observation status endpoint to follow progress.
^obs_[0-9A-HJKMNP-TV-Z]{26}$How many turns the document was split into. Exact when deferred is false, because conversion and splitting happened in-request. 0 when deferred is true; the status endpoint reports the real count once conversion finishes.
The uploaded filename, which also becomes each turn's speaker.
The canonical document format actually converted, e.g. pdf.
Identifier of the persisted original. Empty when ingestion succeeded but source storage was unavailable.
True when the document needed OCR and had more pages than can be read inside one request. It was accepted and is being converted in the background; poll the status endpoint. A conversion failure is that job's error.
Figures kept from the document. Each is stored as a crop and described inline in the turn where it appeared; figures add no turns of their own.
Figures omitted because the document exceeded extraction budgets.
x >= 0Words across every turn the upload produced (text chunks plus figure captions). The input side of what this upload is billed — the same count /observe would bill for the same turns.
x >= 0True when this agent had already ingested a document with these exact bytes, so nothing was read, stored or billed for this request. observation_id, turns_total and source_id then describe the ORIGINAL ingest, not this call. Show it as "already processed" rather than as a fresh upload — claiming new work happened would be untrue, and the progress it reports is already finished.
Where this document stands for this agent. completed with duplicate: true is a reuse; ingesting with duplicate: true means a job was already running and this request joined it rather than starting a second, so its progress is worth polling. Absent on a plain first upload, which is simply in progress.
ingesting, partial, completed, failed True when the agent was reading another file. This file is accepted and waits its turn (the status field queued), then starts by itself. You do not need to send it again.