Files from an agent, waiting for a read, and your usage¶
Three things a new integration meets first: an agent that holds a file (a PDF, an Office file, an image) and must put it in; a document that is read in the background, so the agent must know when it has been read; and a view of what your key holds — what was read, what failed and why, and your limits.
1. A file, from an agent¶
What an agent can do with a file depends on its client:
| client | can it run a command? | how a file gets in |
|---|---|---|
| Claude Code, Cursor, VS Code in agent mode | yes — the agent runs commands in a terminal | an upload link: the agent runs the link's command where the file is. Its shell needs no API key |
| Claude Desktop, and any chat client without a command tool | no | an upload link whose command the agent hands to its user to run; or the text the agent sees, sent as content under a text name (report.txt) — the file's tables and page layout are then lost |
| any program that holds the API key | — | POST /v1/workspaces/{workspace_id}/documents (multipart), the SDK's upload_document, or limma sync <folder> |
A model cannot send a file's bytes as tool arguments. A tool call's arguments are text
the model writes, and a file a person attaches to a chat reaches the model as extracted
text and page images, never as the bytes a reader needs. limma_ingest refuses a
binary file name with text content (binary_format_needs_upload) for that reason.
The upload link¶
Ask limma_ingest for a link instead of sending content — upload_link=True, the
file's filename (its extension says how the bytes are read) and content="":
limma_ingest(content="", filename="report.pdf", upload_link=True,
entity_name="Northwind Lumber Co.", wait_s=60)
Over REST it is the same verb: POST /v1/public/ingest with {"content": "",
"upload_link": true, "filename": "report.pdf", ...}. The reply's status is
awaiting_upload, its source_id is empty, and upload_link names ONE request:
"upload_link": {
"method": "POST",
"url": "https://limma.your-company.internal/v1/public/upload",
"headers": {"X-Limma-Upload-Token": "lmu_…"},
"filename": "report.pdf",
"max_bytes": 104857600,
"expires_at": "2026-10-20T16:05:41.722736+00:00",
"uses": 1,
"command": "curl -sS --fail-with-body -X POST -H 'X-Limma-Upload-Token: lmu_…' -T report.pdf 'https://limma.your-company.internal/v1/public/upload?wait_s=60'"
}
Run command in the directory that holds the file (it reads filename from there), or
make the same request yourself: the file's bytes are the body — not a form around
them (-T or --data-binary @report.pdf, never -F). The reply is the document door's
entry for the file (documents: [{document_id, processing_status, status, ...}]) and,
when the request asked to wait, its wait.
What a link is:
- One upload. The first upload a link admits spends it — whatever then happens to
the bytes. A request refused before its body is read (a multipart body: 415; an empty
one: 422; one that declares more than
max_bytes: 413) does not spend it. Mint another link to send the file again. - Fifteen minutes. After
expires_atit admits nothing (410upload_link_expired). - At most
max_bytes— the deployment's upload limit when the link was minted (on the hosted service, the plan's largest file when that is smaller). - One workspace, and what the file is read under. The workspace,
filename,entity_name,document_kind,retryand the sync fields (external_id,external_version,access) are fixed when the link is minted. The file is then queued exactly asPOST /v1/workspaces/{workspace_id}/documentsqueues one. - The key that minted it. Minting is an ingest: it needs a key that may write
(a viewer key and a key confined to documents are refused 403, a request asserting
X-Limma-Principals400), and a call that names no workspace is served from the key's default workspace. When the bytes arrive, that key is judged again by the document door's rule: revoked, made read-only or scoped elsewhere since the mint, it admits nothing (403upload_link_key_refused; the link is not spent). The upload is made as that key alone: a request that also presents anotherX-API-Keyis refused (400upload_link_key_differs) — the minting key itself may ride along.
The token is a credential, so it stays out of logs. It travels in the
X-Limma-Upload-Token header, never in the URL: an access log between an agent and the
API writes the request line (POST /v1/public/upload?wait_s=60) — a load balancer's,
nginx's and uvicorn's do by default — and none of them writes this header unless it is
configured to. The API stores only the token's SHA-256, and every line the API, the
ingestion worker and the stdio MCP server log is scrubbed of anything shaped like a
token. If a proxy, load balancer or WAF in front of your install logs request headers,
redact X-Limma-Upload-Token there, as you would X-API-Key. The token does reach the
agent — it is in the tool's reply and in the command it runs, and an MCP host that
writes its servers' replies to a log of its own writes it there — which is why a link is
spent by one upload and dies in fifteen minutes.
Inside your network the link names your install: its URL is
<LIMMA_APP_BASE_URL>/v1/public/upload, the address your agents reach the API at.
from docs.examples.roster import new_workspace
made = new_workspace(client, name="uploads")
minted = client.post("/v1/public/ingest", json={
"workspace_id": made, "content": "", "upload_link": True, "filename": "income.md"})
link = minted.json()["upload_link"]
token = link["headers"]["X-Limma-Upload-Token"]
print(minted.json()["status"], minted.json()["ingest_reason"])
print(link["method"], link["url"].endswith("/v1/public/upload"), link["uses"])
print(token[:4], len(token), token in link["url"])
table = b"| | 2023 | 2024 |\n|---|---|---|\n| Revenue | 380 | 412 |\n"
up = client.post("/v1/public/upload", headers={"X-Limma-Upload-Token": token},
content=table)
(entry,) = up.json()["documents"]
print(up.status_code, entry["document_name"], entry["status"], up.json()["wait"])
again = client.post("/v1/public/upload", headers={"X-Limma-Upload-Token": token},
content=table)
print(again.status_code, again.json()["detail"]["type"])
awaiting_upload upload_link_issued
POST True 1
lmu_ 47 False
200 income.md pending None
410 upload_link_spent
(This page's server runs no ingestion worker, so the file stays pending.)
| Status | detail.type |
Meaning |
|---|---|---|
| 401 | upload_link_required |
No X-Limma-Upload-Token header |
| 401 | upload_link_unknown |
A token no link has — or a link its workspace's deletion took with it |
| 410 | upload_link_spent |
The link carried its one upload; mint another |
| 410 | upload_link_expired |
Fifteen minutes have passed; mint another |
| 400 | upload_link_key_differs / principals_not_applied |
A second caller beside the link's key, or a request made for an end user |
| 403 | upload_link_key_refused |
The key that minted the link may no longer upload here |
| 404 | not_found |
The link's workspace was deleted as the upload arrived |
| 413 | The file is over max_bytes (a body that declares no size is cut there, and spends the link) |
|
| 415 | upload_body_is_the_file |
A multipart body: send the bytes themselves |
| 422 | upload_body_missing |
No body |
limma_ingest refuses, before minting, what a link cannot carry (422): content
that is not empty, an idempotency_key (each request mints a new link), a missing
filename or one holding a path, an unknown document_kind, an invalid sync field.
2. Waiting for the read¶
A document is read in the background. wait_s — on limma_ingest (and
POST /v1/public/ingest) and as a query parameter of the upload — bounds how long the
call waits for it: 0 to 120 seconds. Left out, limma_ingest waits the deployment's
default (LIMMA_INGEST_WAIT_S, 45 s). With an upload link, the mint's wait is the
upload's: the link's command carries it (?wait_s=45 when the mint named none). An
upload sent without wait_s does not wait, as the document door does not.
The reply's wait says why the call returned:
wait.outcome |
processing_status |
what to do |
|---|---|---|
read |
complete, partial or failed |
read it. wait.held is {facts, records} the document holds now; a failed one carries wait.failure.reason |
still_reading |
pending / processing |
the bound passed while it is queued or being read. Re-send the same content later — a file through a new link — and the call waits on the same document and never starts a second reading; or read GET /v1/public/usage |
erased |
none | the document was deleted while the call waited; wait.erased_at says when |
no_reader |
pending |
no ingestion worker is alive: nothing was waited for, and the document is read once one starts |
wait also gives wait_s (the bound) and waited_s. A wait holds no lock and no
database connection: it reads the document's row, then pauses — at most a second between
two reads — so deleting the document ends it within a second.
Give the call the time you asked for. A call that asks to wait 120 s answers after up
to 120 s. The Python SDK and the stdio MCP server (python -m epistem.mcp.server) give
such a call a read timeout of the wait plus 30 s; your own HTTP client, and any proxy in
front of the API, must allow as much. An MCP client may stop waiting for a tool's reply
sooner than that — the document is read all the same: call again to read its status.
3. Your usage¶
GET /v1/public/usage (REST and the SDK's usage(); no MCP tool — an agent's menu
pays for every tool on every turn) tells the caller what its key holds and may do:
caller— the key's id (never the key), organization, role, permissions, workspace scope (null: every workspace of the organization), document scope (null, or how many documents it is confined to), the principals asserted for an end user, and itsrate_limit(verbs: the one budget every/v1/publicverb draws on but region and diff, each its own, and the upload, whose link was charged when minted;region;diff— each{limit, window_s}, per key).workspaces— one entry per workspace the key reaches, in id order, a page oflimitat a time (next_afterfor the next):documents(totalandby_status),failures(total, and the newestfailures_limitwith each one'sreasoncode),facts(current facts and prose statements),records(current relation rows),last_activity_at(when a document you may see last arrived or was read),default(the organization's default workspace).plan— on the hosted service, the organization's plan: itsname,trial_days,limits_in_force— whose limits are in force:plan(the plan's own),free_until_first_payment(a paid plan not yet paid for keeps the Free plan's limits until its first payment above $0;until_first_paymentthen gives the plan's ownplan_limitsand how they start,lifts) ortrial(until the first payment, on a plan whose trial has limits of its own — none of the hosted plans has a trial;trialthen gives the trial'slimits, the plan's ownplan_limits,ends_atandlifts), Stripe'ssubscription_status, itsstanding(active;grace, withread_only_from; orread_only—reasonssays why:payment_failed,subscription_ended),subscription_ended_atanddata_deleted_after(from when its content is deleted, 30 days after its subscription ended; null while it runs), andlimits— the limits in force:documents_held,pages_per_month,api_calls_per_month,workspacesandmax_file_mb, each{limit, used, remaining}(usedandremainingare null formax_file_mb, a limit per file);pages_per_monthalso says where the pages went —by_kind:read(by the model) andloaded(without one) — and how a page is counted (counted_as, the rule in words). The counts are the whole organization's, in the calendar month (UTC) for the two monthly ones; a key confined to documents, or a call made for an end user, is shown the plan and its standing withlimitsnull. Null for an organization on no plan — every one inside your own network.
A call over a plan's limit is refused 402 quota_exceeded, naming the limit, your
count, the plan's allowed and the route that changes the plan (upgrade:
POST /v1/billing/portal, with an admin key), and whose limits are in force
(limits_in_force). On a paid plan not yet paid for (free_until_first_payment) it names
the plan's own (plan_allows) and says the plan's limits start once its first payment
succeeds (lifts: add a card in the portal and pay the open invoice). At a trial's own
limit, trial is true and the reply names the plan's own (plan_allows), trial_ends_at
and when it lifts; over
pages_per_month it says how pages are counted (counted_as). Every read keeps working when only a
loading limit is reached, and GET /v1/public/usage is never refused or counted. Once a
subscription is read-only, every operation that loads or changes data is refused 402
account_read_only; questions, reads and deletion keep working.
Counted over the documents the caller may see: a key confined to documents, and a
platform's request for an end user (X-Limma-Principals), count theirs alone, and are
shown a workspace they did not name only when they may see something in it. Never a
document's text, never another organization's workspace. A call that names no workspace
makes none.
r = client.get("/v1/public/usage", params={"workspace_id": WS})
caller = r.json()["caller"]
print(r.status_code, caller["role"], caller["key_id"][:4] + "…", caller["permissions"])
print(caller["workspace_scope"], caller["document_scope"], caller["principals"])
print(sorted(caller["rate_limit"]))
(entry,) = r.json()["workspaces"]
print(entry["workspace_id"] == WS, entry["facts"] > 0, entry["failures"]["total"])
print("plan:", r.json()["plan"])