> ## Documentation Index
> Fetch the complete documentation index at: https://www.sapientpriors.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.




## OpenAPI

````yaml /openapi.yaml post /v1/agents/{agent_id}/threads/{thread_id}/observe-file
openapi: 3.0.3
info:
  title: Priors API
  description: >
    Priors gives you memory agents. An agent learns from the observations you
    send it and answers from what it learned.


    You call this API from your own backend with a partner API key. The key
    identifies your organization, which owns every agent, thread, observation,
    source, and memory it creates. Another organization receives 404 for those
    identifiers. Send `X-SP-End-User` alongside the key to act for one named
    person on your side.
  version: 0.1.0
servers:
  - url: https://app.sapientpriors.com/api
    description: Production
security:
  - partnerKey: []
tags:
  - name: Agents
    description: Create, read, update, and delete memory agents.
  - name: Threads
    description: 'Conversations under an agent: create them, teach them, and ask them.'
  - name: Sources
    description: Files an agent learned from, and the citations that point back to them.
  - name: Authentication
    description: What your key resolves to, and the limits that apply to it.
  - name: Billing
    description: Your organization's credits and charges.
paths:
  /v1/agents/{agent_id}/threads/{thread_id}/observe-file:
    post:
      tags:
        - Threads
      summary: Ingest a document as observed turns
      description: >
        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.
      operationId: observe_file
      parameters:
        - $ref: '#/components/parameters/AgentId'
        - $ref: '#/components/parameters/ThreadId'
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            maxLength: 255
          description: >
            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.
        - $ref: '#/components/parameters/EndUser'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - file
              properties:
                file:
                  type: string
                  format: binary
                  description: The document to ingest.
                allow_duplicate:
                  type: boolean
                  default: false
                  description: >
                    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.
                max_chars:
                  type: integer
                  minimum: 200
                  maximum: 4000
                  description: >
                    Ceiling on the character length of each turn. Defaults to
                    2000, which is set by the memory writer's budget rather than
                    by transport.
                overlap_chars:
                  type: integer
                  minimum: 0
                  description: >
                    Characters 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.
                strategy:
                  type: string
                  enum:
                    - auto
                    - length
                    - sentence
                    - code
                    - structure
                  default: auto
                  description: >
                    Where 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`.
      responses:
        '202':
          description: Document converted and split; its turns accepted for ingestion
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ObserveFileAcceptedResponse'
              examples:
                accepted:
                  value:
                    agent_id: agent_01JZ8X9K6AS3V8SGP0Q9A2P4FH
                    thread_id: thread_01JZ8XASKN4R1HXV4X19A3T02J
                    observation_id: obs_01JZ8XQ8JQZNQDRG6EB7N1M3PV
                    accepted: true
                    turns_total: 24
                    filename: q3-report.pdf
                    doc_format: pdf
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >
            A different file sent while this agent is reading one is not
            refused: it is accepted with `queued: true`. Codes:
            `duplicate_upload_in_progress` (it is reading this same file, or has
            it queued in another thread; you do not need to send it again, and
            what it learns is searchable from every thread of the agent),
            `document_already_ingested` (it has already read this file in
            another thread; send `allow_duplicate=true` to read it again and
            store a second copy), `document_uploaded_elsewhere` (an upload of
            this file in another thread stopped partway or failed; finish it
            there with `finish-learning` to learn only what is left, or send
            `allow_duplicate=true` to read the whole file again here, charged in
            full), or an `Idempotency-Key` conflict (the same key is in flight
            on another request, or was already used for a materially different
            one). `upload_in_progress` is no longer sent.


            `duplicate_upload_in_progress` carries `Retry-After`. The others do
            not: a new key, or an explicit `allow_duplicate`, is what changes
            the answer, not time.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '413':
          description: >
            The document is too large, or splits into more than the configured
            2,000 turns. Split the file or increase chunk size and try again.
            Codes: `document_too_large`, `document_too_complex`,
            `document_too_many_turns`, and — when the configured document reader
            has a lower ceiling than the upload limit —
            `document_too_many_pages` or `document_too_large_for_ocr`, both
            refused before anything is stored or sent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '502':
          description: >
            Conversion of this document failed in a dependency rather than in
            the document itself — the document reader is unavailable. The
            message is deliberately generic; the cause is in the server's logs,
            not in the response. Retrying the same upload later may succeed,
            unlike a 400 or a 413.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
components:
  parameters:
    AgentId:
      name: agent_id
      in: path
      required: true
      description: Unique managed memory-agent identifier.
      schema:
        type: string
        maxLength: 32
        pattern: ^agent_[0-9A-HJKMNP-TV-Z]{26}$
    ThreadId:
      name: thread_id
      in: path
      required: true
      description: Unique thread identifier scoped to the agent.
      schema:
        type: string
        maxLength: 33
        pattern: ^thread_[0-9A-HJKMNP-TV-Z]{26}$
    EndUser:
      name: X-SP-End-User
      in: header
      required: false
      description: >
        Your 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.
      schema:
        type: string
        minLength: 1
        maxLength: 255
        pattern: \S
  schemas:
    ObserveFileAcceptedResponse:
      type: object
      required:
        - agent_id
        - thread_id
        - observation_id
        - accepted
        - turns_total
        - filename
        - doc_format
        - source_id
      properties:
        agent_id:
          $ref: '#/components/schemas/AgentId'
        thread_id:
          $ref: '#/components/schemas/ThreadId'
        observation_id:
          $ref: '#/components/schemas/ObservationId'
          description: >
            Server-issued identifier for the asynchronous ingestion. Pass it to
            the observation status endpoint to follow progress.
        accepted:
          type: boolean
        turns_total:
          type: integer
          description: >
            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.
        deferred:
          type: boolean
          default: false
          description: >
            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:
          type: integer
          description: >
            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_dropped:
          type: integer
          minimum: 0
          description: Figures omitted because the document exceeded extraction budgets.
        words_total:
          type: integer
          minimum: 0
          description: >
            Words 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.
        duplicate:
          type: boolean
          default: false
          description: >
            True 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.
        document_state:
          type: string
          enum:
            - ingesting
            - partial
            - completed
            - failed
          description: >
            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.
        filename:
          type: string
          description: The uploaded filename, which also becomes each turn's speaker.
        doc_format:
          type: string
          description: The canonical document format actually converted, e.g. `pdf`.
        source_id:
          type: string
          description: >
            Identifier of the persisted original. Empty when ingestion succeeded
            but source storage was unavailable.
        queued:
          type: boolean
          description: >
            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.
      additionalProperties: false
    ErrorResponse:
      type: object
      required:
        - error
        - code
        - request_id
      properties:
        error:
          type: string
        code:
          type: string
          description: >
            Machine-readable condition. Branch on this rather than on the status
            where the two differ. All three of these can arrive as a 5xx and
            they want different handling: `service_unavailable` (503) is a
            dependency outage worth retrying, `service_not_configured` (503) is
            the same transport failure that retrying will NEVER clear because a
            feature is not switched on for your organization, and
            `internal_error` (500) is a defect that will fail the same way
            again. Known values: invalid_request, unauthorized, forbidden,
            payment_required, not_found, agent_not_found, thread_not_found,
            conflict, agent_quota_exceeded, duplicate_upload_in_progress,
            document_already_ingested, document_uploaded_elsewhere,
            agent_not_ready, payload_too_large, rate_limit_exceeded,
            service_unavailable, service_not_configured,
            answer_model_unavailable, internal_error, and the document codes an
            upload can return (unsupported_document, document_needs_ocr,
            encrypted_document, malformed_document, empty_document,
            document_too_large, document_too_complex, document_too_many_turns,
            document_too_many_pages, document_too_large_for_ocr,
            document_pages_unexpected, document_service_unavailable, ocr_busy,
            ocr_failed, ingestion_failed).


            answer_model_unavailable is a narrower `service_unavailable` (503):
            the model that writes the visible reply could not be reached after
            retries. It is worth its own code because it is the one dependency
            whose outage a caller may want to word differently -- the turn
            produced no answer at all, and retrying shortly is the right advice.


            agent_not_found and thread_not_found are narrower `not_found`s, sent
            so a client can tell WHICH resource vanished and recover accordingly
            without matching on the English message. Treat any unrecognised
            value as the status code's default meaning rather than rejecting the
            response.
        request_id:
          type: string
          description: >
            Server-issued identifier for this request. Include it when reporting
            a problem so the call can be correlated with server logs.
        details:
          type: string
          description: |
            Present on a 429. Names which control refused the request:
              `user`                    Your per-subject window, counted against `X-SP-End-User`.
              `organization`            Your organization's ceiling, across every key you hold.
              `capacity`                The service is at capacity. Retry.
              `ingestion-organization`  Too much of your own document work is already running.
              `ingestion-capacity`      As above, service-wide.
      additionalProperties: false
    AgentId:
      type: string
      pattern: ^agent_[0-9A-HJKMNP-TV-Z]{26}$
      description: Server-generated agent identifier.
    ThreadId:
      type: string
      pattern: ^thread_[0-9A-HJKMNP-TV-Z]{26}$
      description: Server-generated thread identifier.
    ObservationId:
      type: string
      pattern: ^obs_[0-9A-HJKMNP-TV-Z]{26}$
      description: Server-generated observation identifier.
  responses:
    BadRequest:
      description: >
        The request is not one this route can act on, and sending it again
        unchanged does not help. Usually the payload: a field missing, or a
        value out of range.


        It is also the credential and the headers around it, and each of these
        is refused rather than ignored: `X-SP-End-User` sent empty, or longer
        than 255 characters; and a key on a request that carries
        `Sec-Fetch-Site`, which only a browser sets.


        `code` is `invalid_request` throughout. `error` says which of these it
        was.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unauthorized:
      description: Missing or invalid credentials
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Forbidden:
      description: |
        The credential is valid but may not take this action.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: >
        No such id, or it is not one this key reaches. It is never a 403, so a
        key cannot be used to learn which ids exist.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    TooManyRequests:
      description: >
        A rate window or an admission control refused the request. `Retry-After`
        carries the seconds to wait, and `details` names which control refused.
        Nothing was learned and nothing was charged.
      headers:
        Retry-After:
          description: Seconds to wait before retrying. Always at least 1.
          required: true
          schema:
            type: integer
            minimum: 1
        X-RateLimit-Limit:
          description: Requests the refusing window admits in its period.
          schema:
            type: integer
            minimum: 1
        X-RateLimit-Remaining:
          description: Requests left in the current window. Zero on a refusal.
          schema:
            type: integer
            minimum: 0
        X-RateLimit-Reset:
          description: Unix timestamp (seconds) at which the window admits again.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  securitySchemes:
    partnerKey:
      type: http
      scheme: bearer
      description: >
        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.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.