Grill Ingestion
Grill's ingestion entry point is POST /grill/ingest. Under the hood it runs the same PrimeCut pipeline you would use for raw chunks — parsing, chunking, embedding — but it persists the result inside your project's Grill namespace instead of returning a .poma archive for you to download.
Why a separate endpoint
POST /grill/ingest and POST /primeCut/ingest look almost identical at the wire level. They differ in what happens after the chunks are produced:
| Step | /primeCut/ingest | /grill/ingest |
|---|---|---|
| Parse + chunk | ✅ same pipeline | ✅ same pipeline |
| Embed | Optional, depends on plan | ✅ always |
| Persist to project namespace (vectors + storage) | ❌ | ✅ |
Make doc available to /grill/search | ❌ | ✅ |
.poma archive download via /jobs/{job_id}/download | ✅ | ❌ |
If you want chunks to take home, use PrimeCut. If you want the document to be searchable through /grill/search, use Grill.
One project = one product. A project created with
product:"primecut"cannot call/grill/ingest, and vice versa. See Create a Grill project.
Wire format
Request body is raw file bytes as application/octet-stream. The filename rides in Content-Disposition:
POST /v3/grill/ingest HTTP/1.1
Host: api.poma-ai.com
Authorization: Bearer <project-api-key>
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="manual.pdf"
<raw bytes>Response (201 Created) is a PublicJob:
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-04-30T10:00:00Z",
"properties": {
"file": { "filename": "manual.pdf", "size": 1048576 }
}
}Multipart (multipart/form-data) is not supported for Grill — it returns 403. Use octet-stream.
Fetch from a URL instead of uploading. Set the X-Remote-URL header to a publicly accessible URL and the server fetches the file itself — Content-Disposition and the request body become optional:
POST /v3/grill/ingest HTTP/1.1
Authorization: Bearer <project-api-key>
X-Remote-URL: https://example.com/documents/report.pdfEco pipeline
POST /grill/ingestEco is a drop-in alternative with the same request shape, headers, and PublicJob response — it just runs Grill's cheaper Eco pipeline. Documents land in the same namespace and become searchable identically. Reach for it on high-volume or cost-sensitive corpora where the full pipeline's extraction depth isn't needed; use /grill/ingest when you want maximum extraction quality.
Attaching metadata for filtering
You can tag a document at ingest so queries can filter on it later. These are headers on /grill/ingest (and /grill/ingestEco):
| Header | Stored as | Query-time filter | Use for |
|---|---|---|---|
X-Labels | meta_tags — HMAC'd / opaque, equality-only | meta_tags_any / meta_tags_all | Categorical labels — ["year:1982", "source:treasury"]. Max 64 tags, ≤ 128 chars each, ≤ 4096 chars total. |
X-Meta-Int-1 | plaintext integer | meta_int_1_gte / meta_int_1_lte | A range-queryable integer — recommended convention: Unix epoch seconds. |
X-Meta-Int-2 | plaintext integer | meta_int_2_gte / meta_int_2_lte | A second range integer — recommended convention: revision/version number. |
X-Unencrypted-Strings | unencrypted_strings — plaintext / vendor-visible, glob-matchable | unencrypted_strings_match | Wildcard string filters — {"path": "legal/contracts/acme"}. Max 32 keys; keys [a-z0-9_] ≤ 64 chars, values ≤ 1 KiB. |
X-Labels values are HMAC'd per-tenant before they hit the vector DB — the values stay opaque, so matching is equality only (no prefix or substring search). Encode any structure you need into the string itself (e.g. year:1982). The integer fields are stored plaintext so range comparisons work. X-Unencrypted-Strings is stored unencrypted (the vector DB can read it) precisely so its values can be wildcard/glob-matched — see Retrieval for the search-side filters.
Pick by sensitivity.
X-Labelsis HMAC'd/opaque — safe for sensitive categorical tags, but equality-only.X-Unencrypted-Stringsis plaintext/vendor-visible — use it only when you need wildcard matching and the values aren't sensitive; never put secrets or PII there. (X-Labelsis the same header PrimeCut accepts; on Grill it becomes the document'smeta_tags, queried viameta_tags_any/meta_tags_all.)
Two more standard ingest headers apply to Grill too: X-Base-URL (resolve relative image links in the file) and X-Completion (a webhook URL + headers to notify when the job finishes).
Supported file types
Grill inherits the full PrimeCut format set:
- Documents:
pdf,doc,docx,dotx,rtf,txt,md,html,htm,xml - Presentations:
ppt,pptx,pps,ppsx,pot,potx,key - Spreadsheets:
xls,xlsx,xlsb,xltx,csv,numbers,ods,odc - Images:
png,jpg,jpeg,gif,bmp,tif,tiff,svg,webp,ico,heic,heif,psd - Other:
epub,mobi,djvu,dwg,dxf,dwf,dwfx,vsd,vsdx,ai,eps,ps,prn,xps,oxps,pub,mdi,pages,odp,odf,odt
Async lifecycle
Like every POMA job, Grill ingest is asynchronous. The job_id returned by /grill/ingest plugs into the standard status machinery:
pending ──▶ processing ──▶ done ◀── searchable from this point
└─▶ failed ◀── error.detail in /status response| Use case | Endpoint |
|---|---|
| One-shot polling | GET /jobs/{job_id}/status |
| Live updates | GET /status/v1/jobs/{job_id} (SSE) |
| Cancel / cleanup | DELETE /jobs/{job_id} (best-effort) |
The job's download link is not populated for Grill jobs — there is no .poma to fetch. Grill stores the artifacts inside its own namespace.
What happens at status: done
When the job transitions to done, the following are true atomically:
- The document appears in
GET /grill/docsfor this project. GET /grill/docs/{docId}returns itsDocInfo(chunk counts, page count, ingest timestamp, source job id).POST /grill/searchandPOST /grill/searchInDoccan retrieve passages from it.
docId is the document identifier Grill assigns at ingest. It is derived from the filename (sanitised) plus a project-scoped salt; you find the canonical value in DocInfo.doc_id after the job finishes. Use that exact string for doc_filter and for /grill/docs/{docId} lookups.
Re-ingesting the same file
Re-uploading a file with the same effective docId replaces the existing document — old vectors and storage are discarded. There is no append mode today; an updated PDF fully supersedes the previous version. If you need version history, ingest each version under a distinct filename so the docId differs.
If you want to remove a doc cleanly before re-ingest, use DELETE /grill/docs/{docId} (Document management).
Errors you will see
| Status | When | What to do |
|---|---|---|
400 | No X-Remote-URL and a missing/invalid Content-Disposition, unsupported MIME, or empty body | Fix the headers; check the file is non-empty (or supply X-Remote-URL). |
401 | Missing or invalid Bearer token | Use a project API key — see Authentication. |
403 | Caller's project is primecut, or the request is multipart | Create a Grill project; switch to octet-stream. |
500 | Server-side parse failure | Retry once; if it persists, contact support with the job_id. |
Practical patterns
Bulk ingest a folder.
for f in corpus/*.pdf; do
curl -sS -X POST "$GRILL/grill/ingest" \
-H "authorization: Bearer $GRILL_KEY" \
-H "content-type: application/octet-stream" \
-H "content-disposition: attachment; filename=\"$(basename "$f")\"" \
--data-binary "@$f" \
| jq -c '{file: "'"$f"'", job: .job_id}'
doneWait for jobs to reach done before issuing search calls — search will return 404 (or simply miss the document) for content that has not finished indexing.
Ingest from the Python SDK.
The recommended path is the SDK's Grill client, which handles the octet-stream framing, polling, and status streaming for you. It reads the project API key from POMA_GRILL_API_KEY.
# pip install poma
from poma import Grill
g = Grill()
result = g.ingest("manual.pdf") # submit + poll + return when done
print(result.job_id, result.status, result.usage)For batch ingestion, submit all jobs first and collect later — useful when you want submissions to happen quickly and the long wait to happen in parallel:
from poma import Grill
g = Grill()
job_ids = [g.submit(p) for p in ["a.pdf", "b.pdf", "c.pdf"]]
results = [g.collect(jid) for jid in job_ids]Or, in async code, run the waits concurrently with AsyncGrill:
import asyncio
from poma import AsyncGrill
async def ingest_all(paths: list[str]) -> None:
async with AsyncGrill() as g:
results = await asyncio.gather(*(g.ingest(p) for p in paths))
for r in results:
print(r.job_id, r.status)
asyncio.run(ingest_all(["a.pdf", "b.pdf", "c.pdf"]))Full method signatures: Grill reference, AsyncGrill reference.
Next
- Retrieval — once the doc is in, how does search behave?
- Document management — list, inspect, delete.