Every endpoint is under /v1. All responses are JSON unless noted. Every endpoint except
/v1/health, /v1/stats and /v1/auth/* requires Authorization: Bearer <github_token>
(PRD §9.1) — with one exception, /v1/quality, which takes a bearer token that is not a
GitHub token (§0c).
These commands are copy-pasteable. They are the same calls exercised by test/e2e.sh, which
runs the whole lifecycle against mock GitHub + mock model services and asserts every response.
Requests are rate limited per client, and every response says how much of the budget is left:
§3.2 has the numbers, the headers, and what a 429 looks like.
export BASE=http://localhost:8080/v1curl -s "$BASE/health"
# {"status":"ok","service":"equalify-iris"}How much this deployment has actually made accessible. No token: the browser app shows it to visitors before anyone signs in, and it is the number the project celebrates with.
curl -s "$BASE/stats"{ "pages_processed": 1284, "documents_processed": 212, "since": "2026-05-22T18:00:00.000Z",
"quality": { "window_days": 30, "documents": 212, "clean_rate": 0.93, "mean_rounds": 1.8 } }pages_processed— distinct page images Iris has converted to accessible HTML. A session counts once it has reachedready_for_review, so an upload that has never reached it — still queued, still running, or failed on its first run — is not in the number, and a feedback re-run does not count its pages again: this is a tally of pages made accessible, not of model calls. Note the asymmetry with afailedstatus: a session that completed once and then failed a re-run stays counted (see "the number only ever goes up" below).documents_processed— sessions counted, on the same basis. A 40-page PDF is one document and forty pages.since— when the earliest counted document finished, ornullbefore anything has, so a client can say "since May 2026" without hardcoding a launch date.quality— how well it went, ornullwhen the deployment has too few recent documents to say (below). Present either way, so a client can tell "nothing to report" from an older server without the field. Four fields, and no more:window_days— the window the two rates cover, echoed so a client can write "over the last 30 days" without hardcoding it. Fixed at 30 and not caller-adjustable: this endpoint takes no parameters, which is what keeps its single shared cache entry correct for everyone, and a public?days=would let anyone narrow the window until the denominator was one document.documents— the denominator: documents delivered inside the window, flawless ones included. Distinct fromdocuments_processed, which is all-time.clean_rate— share (0–1) of those documents whose review loop ended with the reviewer reporting nothing left open. The complement of §0c'sunresolved_rate, stated the positive way round because this one is read by someone deciding whether to trust Iris with a file. Note what it is not: it is the Reader Agent's remaining-issue list, not the final axe result, so a document carrying a violation the reviewer never raised still counts here. §0c can see that gap (itsrulescome from the final lint of the same run); this field cannot, which is why the demo page's sentence credits the reviewer rather than saying the document came out clean.mean_rounds— mean reader/editor passes per document. 0 is the good value: the loop stops as soon as the Reader finds nothing, so a document that reads clean immediately contributes 0.
quality is null until the window holds at least 20 documents (PUBLIC_QUALITY_MIN_DOCUMENTS),
and that floor is a privacy control, not a presentation choice. A rate over three documents is not
a measurement — one bad afternoon reads as "67% clean" on a front page — and, more to the point, on
a quiet deployment the aggregate is the individual: "50% finished clean" next to a document count
of four is a statement about identifiable people's uploads. The floor is enforced in
Store.publicQuality, not in the route, so a future route edit that reads the fields it wants
cannot walk around it. Below the floor the answer is null rather than zeros, because a route has
no way to tell a real 0% from an absent one and "0% clean" is the worst claim the field can make.
The floor bounds a snapshot, not a series of them. documents and clean_rate together give an
exact integer count of unresolved documents, and documents_processed deltas were already
inferrable before quality existed — so an observer polling this endpoint on a deployment near the
floor can difference the readings over days and attribute unresolved status to a single document,
which is the inference the floor exists to prevent. Nothing identifying is exposed (no login, no
filename, no per-document timestamp), so out-of-band knowledge of who uploaded when is needed for it
to mean anything, and it is inherent to publishing any windowed rate rather than specific to these
fields. An operator for whom that matters should treat it the same way as the page-count delta above:
keep the endpoint off the public internet.
Volume is all-time while quality is windowed, which is deliberate rather than an inconsistency: an all-time rate converges and stops responding to a fix, while an all-time page count is the achievement being reported. No rule ids here, unlike §0c — a standing list of what Iris still fails at belongs in front of the people who would fix it, not on a public page.
The number only ever goes up. It is derived from a write-once first_completed_at stamp rather
than from current status, precisely so that asking Iris to re-run a finished document — which
moves it back to queued, and possibly on to failed — cannot make the public count dip. For
databases that predate that column, sessions already in ready_for_review/closed are backfilled
from updated_at, so nothing already converted is dropped from the count.
One session is missed by that backfill, once: one that had completed and was mid-re-run at the
moment the upgraded build booted. It is not ready_for_review/closed at that instant, and the
startup sweep then marks it failed, so it is excluded for good rather than until the re-run ends.
Counting in-flight sessions instead would be worse — it would credit first runs that had produced
nothing — so the tally undercounts by that one document. Only the upgrade moment is affected.
Everything here is a deployment-wide aggregate: no session ids, logins, user ids, filenames or
content. It is not, however, free of per-upload information: the delta between two reads is one
— documents_processed +1 with pages_processed +40 means a 40-page document finished in that
window, and on a quiet deployment the aggregate is the individual. Nothing identifying follows from
it (no who, no what), but an operator who treats document sizes as sensitive should keep this
endpoint off the public internet. Responses are cached for 60 seconds
(Cache-Control: public, max-age=60), which coarsens when a conversion shows up but not the page
count — so a page you just converted may take up to a minute to appear.
How good the output has been, as opposed to how much of it there was. Iris already measures itself on every run — how many reader/editor rounds a document needed, which axe-core rules its HTML still violates, whether a link from the source went missing — and this is the only place those measurements are readable across sessions (PRD §7.16).
It exists for one caller: .github/workflows/quality-report.yml, which reads it weekly, compares
the rates against thresholds held in that workflow, and files a GitHub issue when one is crossed.
curl -s -H "Authorization: Bearer $IRIS_QUALITY_TOKEN" "$BASE/quality?days=30"{
"window_days": 30,
"documents": 212,
"since": "2026-07-14T00:00:00.000Z",
"mean_rounds": 1.8,
"unresolved_rate": 0.07,
"links_dropped_rate": 0.02,
"lint_error_rate": 0,
"rules": [
{ "id": "heading-order", "impact": "moderate", "documents": 81, "share": 0.382, "nodes": 240 }
]
}documents— delivered documents in the window, including flawless ones. This is the denominator for every rate below, and it is the whole reason the tally is stored the way it is: a clean run produces no violation rows, so counting "documents that had a problem" would divide by the bad documents alone and report every rate near 100%.mean_rounds— mean editor passes per document, againstdefaults.max_review_iterations. The loop stops as soon as the Reader finds nothing, so a document that reads clean on the first look contributes0: low is good, and0.0across the window means nothing needed fixing.null, not0, when nothing has run — otherwise an empty deployment reports the best possible score.unresolved_rate— share of documents that finished with issues the review loop could not resolve. Only the count of those issues is used, never their text — see below.links_dropped_rate— share of documents where anhrefpresent before the copy editor was missing after it.lint_error_rate— share of documents whose lint pass errored instead of running. Recorded explicitly rather than inferred, becauserunAxedegrades to "no violations" when axe cannot run at all, so a broken linter would otherwise read as a deployment that got better.rules[]— axe-core rule ids, per document:documentsis how many documents violated the rule andshareis that overdocumentsabove, withnodes(total offending elements) alongside rather than folded in. One pathological scan with 400 bad headings is a worsenodesand the samedocumentsas any other single failure — "fails on 40% of documents" names a prompt defect, while "is 90% of our violations" moves when an unrelated rule is fixed.window_days— the window actually used, echoed back.?days=Nis clamped to 1–365 and a garbled value falls back to 30, so read this rather than assuming what you asked for. Windowed rather than all-time on purpose: an all-time rate converges and stops responding to a fix.
Nothing here can carry document content, and that is a hard constraint rather than a
convention. The consumer copies these values into a public GitHub issue, and the documents
behind them are user uploads — at the reference deployment, student records. Rule ids come from
axe-core's fixed vocabulary and are safe to publish; the review loop's unresolved-issue
descriptions are model-written prose about one person's document, which is why only their count
appears, and dropped hrefs came from the user's own PDF, which is why only their count appears.
A field added here that quoted a document would leak it through a path no reviewer of the workflow
would think to check.
Off unless configured, and unset means 404, not 401: a deployment that has not opted in
does not acknowledge the endpoint at all. Set server.quality_token
(IRIS_QUALITY_TOKEN) to a long random value — openssl rand -hex 32 — and restart. This is the
one endpoint not behind the GitHub user auth: the data belongs to no user, and the caller is a CI
job with no GitHub identity, so a per-user credential is the wrong shape for it. Responses carry
Cache-Control: no-store and are cached in-process for five minutes.
Two more values live in the repo that reads it — the QUALITY_URL variable (the deployment's
origin, no /v1: the job appends the path, and a value carrying one produces a 404 that looks
exactly like a deployment which never opted in) and the QUALITY_TOKEN secret, byte-for-byte the
token above. Verify the pair with gh workflow run quality-report.yml -f dry_run=true rather than
waiting for the weekly schedule; README's "Weekly quality report" section has the full procedure,
including why a green run that declines to file is the expected result on a young deployment.
GitHub is the only auth mechanism, and a GitHub token is required on every API call — there is no anonymous mode, no API key and no second SSO provider. That is a design decision, not a gap: your token is what files your session's feedback back to the shared agent library, under your own GitHub identity. Using Iris and improving it for the next person are the same act (PRD §12). If you would rather not contribute, this is not the service to run.
By default the service uses a bundled GitHub App — you don't create or configure anything; just run the device flow below and approve in your browser.
The consent screen requests no repository access at all. Iris is a GitHub App, so the one permission it needs — write access to issues on the upstream repo — comes from the app being installed on that repo, not from your authorization. What your token grants is your identity, which is what puts your name on the feedback your session contributes. It cannot read your code, and it cannot touch any repository other than the upstream one the app is installed on.
Your token is never written to disk. It is read from the Authorization header, held in memory
for the duration of the run it authorizes, and discarded — revoke it any time at
github.com/settings/applications and the service loses
that access within five minutes (see the README's "What happens to your token").
Operators: an earlier build did store tokens, in a github_token column. There is no migration —
delete any data/iris.sqlite from before that change and let users re-authorize. The service
refuses to start against such a file rather than adopting it, since the old table would break
first-time logins and would still hold live plaintext tokens, making the paragraph above false for
that deployment.
# Begin: returns a code to type into the browser.
dev=$(curl -s -X POST "$BASE/auth/github/device")
echo "$dev"
# {"device_code":"...","user_code":"WXYZ-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}
# Open the verification_uri in a browser and enter the user_code, then poll:
DEVICE_CODE=$(echo "$dev" | jq -r .device_code)
curl -s -X POST "$BASE/auth/github/device/poll" \
-H 'content-type: application/json' \
-d "{\"device_code\":\"$DEVICE_CODE\"}"
# while pending -> 202 {"status":"pending","error":"authorization_pending"}
# once approved -> 200 {"access_token":"gho_...","token_type":"bearer"}
export TOKEN=gho_xxx # paste the access_token
export AUTH="Authorization: Bearer $TOKEN"GET /v1/auth/github/start -> 302 redirect to the GitHub consent screen
GET /v1/auth/github/callback -> 200 {"access_token":"gho_...","token_type":"bearer"}
/start issues a state value and redirects to GitHub; after the user approves, GitHub calls
/callback?code=...&state=... and the service returns the access token.
curl -s -H "$AUTH" "$BASE/me"{
"github_login": "iris-tester",
"github_user_id": 4242,
"upstream_repo": "https://github.com/example/iris",
"defaults": { "max_review_iterations": 3 }
}upstream_repo is where this deployment files your contributions. There is no fork_repo field:
contributions are filed as issues, so no fork is ever created (deviation from PRD §7.13).
multipart/form-data. Repeat images once per file; the order of the parts is the
processing order (not the filename). images is the only part the endpoint reads — there are
no per-session options. (A config part used to override max_review_iterations for one
session; it was removed, and sending one now is ignored rather than an error. The cap comes from
your account default, seeded from the deployment's defaults.max_review_iterations — see
GET /v1/me.)
create=$(curl -s -X POST -H "$AUTH" "$BASE/sessions" \
-F "images=@page-001.png" \
-F "images=@page-002.png")
echo "$create"
export SID=$(echo "$create" | jq -r .session_id){ "session_id": "ses_01HXYZ...", "status": "queued", "image_count": 2, "created_at": "..." }Accepted file types: PNG, JPEG, GIF, WebP, and PDF. A PDF is rasterized server-side into one image per page (in page order) and processed like any other page sequence. Total pages (across all parts) are capped per deployment.
Each image part also has a size limit, and an upload over it is rejected here with a 400
rather than accepted and failed later. The limit is not Iris's own: an uploaded image is passed
to the vision model byte for byte, so the model's per-image cap is the cap — currently 5 MB
base64, which is 3.75 MB on disk, on Amazon Bedrock. Ask the deployment instead of
assuming, since it moves with the configured model and provider:
GET /v1/limits. A PDF is not measured against that
limit — the file you send is not what reaches the model, since Iris rasterizes its pages at its
own resolution — but each rendered page is, and a page over it fails with a 400 naming the
page and the PDF. That happens with large-format pages: rasterizing at a fixed DPI means the
page image scales with the physical page, so a letter page renders well inside the limit and an
ARCH-D drawing does not.
Pixel dimensions are mostly not a limit worth planning around, and this is the common
misdiagnosis: a large-but-light image converts fine, while a small-but-heavy photo is what
fails. The model downscales anything over its long-edge limit (1568 px on Sonnet 4.6, 2576 px
on Claude 4.7 and later) before reading it, so extra pixels buy no fidelity — they only spend
bytes against the cap. Re-saving a 12-megapixel scan at 1568 px on the long edge, or as a JPEG,
loses nothing the conversion would have used. There is one hard ceiling, max_dimension_px
(8000 px on either edge): above it the model rejects the request outright rather than
downscaling, so Iris rejects it here instead, reading the dimensions from the file's header.
GET /v1/limits — unauthenticated. What this deployment accepts, resolved from the model and
provider it is configured to use, so a client never has to hardcode numbers that change when
the model does. The demo page states its file limits from this endpoint.
curl -s "$BASE/limits" | jq{
"max_pages": 25,
"image": {
"max_bytes": 3932160,
"max_long_edge_px": 1568,
"max_dimension_px": 8000,
"media_types": ["image/png", "image/jpeg", "image/gif", "image/webp"],
"extensions": [".png", ".jpg", ".jpeg", ".gif", ".webp"],
"hint": "Each image must be under 3.7 MB and in one of PNG, JPEG, GIF, WEBP format. …"
},
"pdf": { "max_pages": 25 },
"upload": { "max_files": 25, "max_request_bytes": 134217728 },
"rate_limits": {
"general_per_minute": 240,
"auth_per_minute": 60,
"upload_per_minute": 12,
"max_upload_memory_mb": 256,
"window_seconds": 60
}
}max_bytes is what POST /v1/sessions enforces per image part, and hint is the same sentence
its 400 carries — quote it rather than composing your own, and the two cannot disagree.
max_bytes and max_dimension_px are both enforced — the second only when the dimensions can be
read from the file's header, since a header Iris cannot parse must not become a rejection;
max_long_edge_px is advice, not a limit —
nothing rejects an image for exceeding it, because the model downscales past it instead of
failing. If a client can only surface one number, surface max_bytes: it is what nearly every
rejected upload will have broken. The model and provider that produced these numbers are
deliberately not named here; that is deployment detail, and this endpoint answers a question
about files.
upload is what one request may be, as opposed to what one image may be: max_files parts and
max_request_bytes across all of them. They are refused at different moments, which matters if you
are streaming: the byte total is checked before the body is read (or counted as it arrives, when the
request declares no length), while the part count is refused during parsing, once a part past
max_files appears. rate_limits
is how often you may ask (§3.2), and is null on a deployment that does not limit requests in the
app — which means "not limiting", not "unknown".
A PDF's links survive, which rasterizing alone would not manage: a link is an annotation
over the page rather than something drawn on it, so the page image carries the link text and
none of its target. The link targets are read out of the file separately and given to the page
agent as ground truth, and the output's <a href>s are checked against them — the run log
carries a page_links line per page that had any, and page_links_missing /
page_links_unrecovered when one did not make it into the HTML. Three kinds are dropped on
purpose: links to a destination inside the same document (the page they point at is in the
delivered HTML already); any URL whose scheme is not http(s), mailto, tel, or ftp — a
PDF can carry a javascript: action, and that is not something to re-emit into a document —
and any URL containing a character that would end the attribute it is written into (a quote,
<, whitespace), which no legitimate URL carries unencoded. A link over an image with no text
under it has nothing to attach to and is lost.
Recovering a link never costs a page its structure. When a page passed its fidelity check and
is re-rendered only to attach a link, the rewrite is verified in turn, and one that lost
something — a heading level, a <th scope> — is discarded in favour of the fragment that
passed, logged as page_links_correction_rejected. A link is additive; the accessibility of a
page that already checked out is not something it may be paid for with.
status is queued on creation and becomes running when the pipeline actually starts. Those are
usually the same instant, but a deployment runs at most defaults.max_concurrent_runs pipelines at
once (default 2): beyond that, the session waits in queued — in FIFO order, for as long as it
takes — rather than being rejected. Nothing is lost; the upload is already stored. If a session sits
in queued, check its run log for run_queued / run_dequeued to see the wait rather than
assuming a hang.
A deployment limits requests at the HTTP layer, because it is a single process whose reads hit
SQLite synchronously — one client's runaway loop is felt by everyone, including the runs already
in flight. Three budgets, each per minute, all published by GET /v1/limits:
| Budget | Applies to | Default | Counted per |
|---|---|---|---|
general_per_minute |
everything under /v1 except /v1/health |
240 | token if validated, else address |
auth_per_minute |
/v1/auth/* |
60 | address (there is no token yet) |
upload_per_minute |
POST /v1/sessions |
12 | user |
Every response carries the budget it was counted against, so a client can pace itself without being refused first:
curl -si "${AUTH[@]}" "$BASE/sessions" | grep -i '^ratelimit'
# ratelimit: limit=240, remaining=238, reset=41
# ratelimit-policy: 240;w=60Over budget is a 429 with Retry-After (seconds) and the standard error body:
{ "error": { "code": "rate_limited",
"message": "Too many requests: this deployment allows 240 per minute per client. Retry in 41s.",
"details": { "limit": 240, "window_seconds": 60, "retry_after_seconds": 41 } } }Wait Retry-After seconds; do not retry immediately. A tight retry loop spends the next
window before it opens. If you are polling a session, poll every 2–5 seconds — a conversion takes
minutes, and nothing changes faster than that.
Two more refusals concern uploads specifically. Both normally answer before the body is read, so a rejected upload costs you nothing but the round trip — the one exception is a request that declares no length, which can only be refused while it is arriving:
413 upload_too_large— the request is bigger thanupload.max_request_bytes. Retrying it unchanged will fail again; split the batch across sessions. Normally answered from the declaredContent-Lengthbefore a byte of body is read; a request that declares no length (chunked) is counted as it arrives and cut off at the same ceiling, mid-upload, withreceived_bytesindetailsinstead ofdeclared_bytes.429 rate_limitedwithmax_upload_memory_bytesindetails— too much upload is arriving at once across all callers (max_upload_memory_mb). This is about bytes in flight, not your request count, so small uploads are essentially never refused for it. Retry in a few seconds.
A token identifies you no matter which address you arrive from, so signing in is what gets you your own budget: unauthenticated requests, and any bearer token this deployment has not validated, count against your address — which you may be sharing with an entire campus.
The pipeline runs asynchronously; poll until status is ready_for_review (or failed).
curl -s -H "$AUTH" "$BASE/sessions/$SID" | jq{
"session_id": "ses_01HXYZ...",
"status": "running",
"phase": "extraction",
"iterations_completed": 0,
"iterations_max": 3,
"image_count": 2,
"created_at": "...",
"updated_at": "..."
}status is one of queued, running, ready_for_review, closed, failed.
phase is one of extraction, assembly, review, done, and is only meaningful while
status is running — a queued session reports the phase it will start in, not one it has
reached. These are the four phases the pipeline enters; triage and reconciliation appear in
PRD §6/§7.2/§7.6 but are not implemented and are no longer emitted, so a client should not
branch on them. Treat the list as open anyway: fall back to displaying the raw value rather than
showing nothing for a phase you don't recognize.
A simple wait loop:
until [ "$(curl -s -H "$AUTH" "$BASE/sessions/$SID" | jq -r .status)" = "ready_for_review" ]; do
sleep 2
donecurl -s -H "$AUTH" "$BASE/sessions/$SID/output" -o output.htmltext/html — clean, content-only accessible HTML. Provenance comments (@source, @agent,
@fragment) are not included, a deliberate deviation from PRD §7.4; provenance lives in the
run log instead (step 7). An <!-- @unresolved --> comment listing outstanding issues is
appended if the review loop hit its iteration cap. Returns 409 while the session is still
running.
Triggers a new run within the same session, with the feedback injected as a top-level
instruction to every agent (PRD §7.12). The prior output is snapshotted to
sessions/<id>/history/ so it can be reverted to.
curl -s -X POST -H "$AUTH" "$BASE/sessions/$SID/feedback" \
-H 'content-type: application/json' \
-d '{"feedback":"The footnote on page 4 was inlined as body text. Keep footnotes distinct."}'
# 202 {"session_id":"ses_...","status":"running","phase":"extraction"}Then poll status again as in step 4. A re-run is subject to the same max_concurrent_runs cap as a
new upload, so the 202 may instead report {"status":"queued","phase":"extraction"} — accepted, waiting
for a slot. Either way the session is no longer ready_for_review, so a second feedback POST gets a
409 until this run finishes.
A re-run on a session that already produced output builds on the existing document rather
than regenerating it, and is routed by what the feedback is about (visible in the run log as a
feedback_scoped event):
| Scope | What runs | Typical feedback |
|---|---|---|
document |
Re-lint the saved body, then the feedback-aware review loop. No re-extraction. | tone, wording, ordering, an accessibility rule |
extraction |
The named pages go back to the page agent with their source image, then reassemble + review. Other pages keep their prior fragments. | "the revenue figure on page 2 is wrong", missed or misread content |
The second case exists because the Reader never sees the source images (by design, §7.8 — it
reads the way a screen-reader user does), so a misreading of the source is invisible to it: no
issue is raised, and the loop has nothing to act on. Routing is biased toward the cheaper
document path: if the pages can't be localized, or the feedback claims more than half the
document, it falls back rather than re-extracting broadly.
curl -s -H "$AUTH" "$BASE/sessions/$SID/logs"application/x-ndjson — one JSON object per line (agent calls with git-SHA / inline-content
version pinning, model-call timing, no-content signals, phase transitions).
Useful events to grep for:
type |
Meaning |
|---|---|
run_queued / run_dequeued |
The run's wait for a concurrency slot: how busy the queue was when it was admitted (running of limit, plus waiting), and waited_ms when it actually started. A large waited_ms means the deployment is saturated, not that this run is slow. |
feedback_scoped |
How a feedback re-run was routed (document vs extraction, and which pages) |
reextract_start / reextract_complete |
Which pages went back to the page agent |
editor_images |
How many source images the Copy Editor received this round (attached of of, plus pages). attached == of on a multi-page document means at least one issue in that round carried no page attribution, so the round fell back to sending everything. |
reader / editor |
Per-iteration review-loop progress (issue counts) |
A machine-readable health summary distilled from the run log — built for maintainers, human or AI, to spot what's slow or stuck.
curl -s -H "$AUTH" "$BASE/sessions/$SID/diagnostics" | jq{
"session_id": "ses_...",
"status": "running",
"phase": "extraction",
"started_at": "2026-05-22T16:25:01Z",
"elapsed_ms": 92000,
"in_flight": {
"agent": "table", "model": "us.anthropic.claude-sonnet-4-6",
"provider": "bedrock", "capability": "vision",
"since": "2026-05-22T16:26:12Z", "waiting_ms": 41000
},
"in_flight_count": 3,
"concurrency_factor": 3.8,
"phase_durations_ms": { "extraction": 60100, "review": 24000 },
"model_calls": { "count": 7, "failed": 0, "total_ms": 51000, "avg_ms": 7285, "max_ms": 14300 },
"tokens": { "input": 48200, "output": 19400, "cache_read": 0, "cache_write": 0, "calls_reported": 7 },
"by_agent": { "page": { "count": 2, "total_ms": 28200, "max_ms": 15100,
"input_tokens": 21400, "output_tokens": 9100,
"cache_read_input_tokens": 0, "cache_creation_input_tokens": 0 } },
"slowest_calls": [ { "agent": "table", "model": "...", "capability": "vision", "duration_ms": 14300, "ok": true } ],
"errors": []
}The key field for "is it hung?" is in_flight: a non-null value with a large waiting_ms
means a model call started and hasn't returned (the likely culprit). Because pages are
extracted in parallel, several calls can be open at once — in_flight reports the
longest-waiting one and in_flight_count how many are open in total. concurrency_factor
is total model-call time ÷ wall-clock elapsed: ~1 means calls ran serially, and roughly
extraction_concurrency during a parallel extraction phase — a value near 1 on a multi-page run
means parallelism isn't happening. slowest_calls and phase_durations_ms show where time goes;
errors lists failed calls.
tokens is what the run consumed, and by_agent carries the same four counts per agent
(under the names the run log uses: input_tokens, output_tokens, cache_read_input_tokens,
cache_creation_input_tokens) — so "which agent is slow" and "which agent is expensive" can be
answered separately, because they are often different agents. Deliberately no dollar figure: the rate depends on the provider,
region and model, all of which are deployment config, so the token counts are reported and
whoever holds the price sheet does the multiplication. The four counts bill at four different
rates and are never summed here; note that input excludes tokens read from the cache, so the
whole prompt is input + cache_read + cache_write.
One caveat on that sum, for whoever is doing the multiplication. It is exact on a provider that
reports the four counts as disjoint sets, which is what the Anthropic-shaped APIs do. On an
OpenAI-shaped one, cache reads are reported inside the prompt total and are subtracted back out
here, but whether cache writes are also inside it is undocumented — so where they are, they
are counted once as input and again as cache_write, and the sum is high by that amount.
Over-counting is the deliberate choice: the alternative subtracts a number that may never have
been in the total, which understates the prompt and reports a cache as cheaper than it was.
calls_reported is how many of model_calls.count reported any usage at all. When it is lower
than count, these sums cover only part of the run — a cost derived from them is a floor, not
an estimate. Some upstreams report nothing; a call that stalls knows its prompt size but never
learns its output size. Failed calls are counted, because a truncation has already paid for
a full ceiling of output and a stall for its prompt.
curl -s -H "$AUTH" "$BASE/sessions?limit=20"
curl -s -H "$AUTH" "$BASE/sessions?status=ready_for_review"{ "sessions": [ { "session_id": "ses_...", "status": "ready_for_review",
"image_count": 2, "created_at": "...", "updated_at": "..." } ],
"next_cursor": "2026-05-22T18:00:00.000Z|ses_01HXYZ..." }limit is 20 by default and capped at 100. Anything not an integer of at least 1 —
0, negative, fractional, non-numeric — is the default, not an error: one rule, so two
equally invalid values can't get page sizes differing by a factor of twenty.
Paginate by passing cursor=<next_cursor> verbatim — it encodes both halves of the
sort key (created_at|session_id), because created_at alone is not unique: sessions
created in the same millisecond tie on it, and paging on a non-unique key skips and
repeats rows at page boundaries. Treat it as opaque; the shape is documented so a paging
bug is readable in a request log, not so clients can construct one.
"Verbatim" means the value, not the URL: percent-encode it when you build the query
string (%7C for the |). A raw | is not a legal query character per RFC 3986 — curl,
browsers and Express all accept it, but a strict URI type (java.net.URI) or a strict proxy
will reject the request, and the error will not point back here. Use whatever your client
calls --data-urlencode; the examples below do.
next_cursor is null on the last page — including when that page is full. Stop when it
is null rather than when a page comes back short.
Send the cursor back byte-for-byte. It is validated against the exact format the store
writes (UTC ISO-8601 with milliseconds), and anything else is a 400 invalid_request — not
a silent restart from page one. That includes values which are perfectly good timestamps
for the same instant: 2026-05-22T18:00:00Z (milliseconds dropped) and
2026-05-22T19:00:00.000+01:00 (an offset instead of Z) are both rejected, because the
cursor is compared as a string and either one sorts above every stored value. If you
round-trip cursors through a date type, you will reformat them; keep them as strings.
One exception, and the only case where a page can still lose rows: a cursor from before
this endpoint became compound is a bare timestamp with no |, and it is still accepted
rather than 400'd — so a client paginating across the deploy that introduced the compound
cursor keeps working, but that one request skips any sessions tied on that timestamp.
It clears itself on the next page, since the cursor it hands back is compound. If a client
reports a gap during an upgrade window, this is why; re-listing from the start is the fix.
# Walk every page.
cursor=""
while :; do
page=$(curl -s -H "$AUTH" --get ${cursor:+--data-urlencode "cursor=$cursor"} "$BASE/sessions?limit=50")
echo "$page" | jq -r '.sessions[].session_id'
cursor=$(echo "$page" | jq -r '.next_cursor // empty')
[ -z "$cursor" ] && break
doneLocks the output and deletes tmp/<id>/. Requires status = ready_for_review (else 409).
Contributions are handled automatically during the run (see below), so close does not open PRs.
curl -s -X POST -H "$AUTH" "$BASE/sessions/$SID/close"{ "session_id": "ses_...", "status": "closed" }Every session gives something back, and there is no opt-out. This is why the API requires a GitHub token at all (PRD §12).
Two things get filed as GitHub issues on the upstream repo, server-side during the run. Each is identified by its title prefix rather than by a label, because GitHub silently drops labels set by a filer without push access — which is most filers here:
- New agent suggestions (
New agent suggestion: <type>) — when the extractor meets content a dedicated specialist agent would handle better than the general pass, Iris drafts that agent and files it with the code + context. - Agent improvements (
Agent update proposal: <agent> — <lesson>) — when your/feedbackproduces a change that generalizes beyond your document, and it survives the agent's regression fixtures. The title carries a short slug of the lesson as well as the agent, and the issue body carries the lesson, how many sessions have reported it, and your feedback verbatim.
Both are filed with your token, so the issue carries your GitHub identity and the credit is
yours. There is no PR/fork flow (deviation from PRD §7.13): /close returns no prs_opened and
requests accept no skip_prs.
Both dedupe against an open issue with the same title, found by searching GitHub — a new agent
suggestion skips, an agent improvement comments on the existing issue with your session and the
updated proposal. The improvement path has to do more than skip because its title is not unique per
document: every proposal targets the same page.md, so before the lesson slug was in the title, one
open issue silently discarded every later lesson from every user for as long as it stayed open. A
lesson never disappears now — worst case it lands as a comment on a related issue.
That search is the only dedupe, and GitHub's search index is not immediate — two sessions that report the same thing within a minute or two of each other can each file one. Deliberate: a duplicate costs a maintainer one click, and hard-failing the check would cost you your document.
Filing never fails your run — a contribution is a side effect, and a GitHub outage must not cost
you a document you already paid for. It is logged as agent_issue_failed instead, with a hint
naming the likely cause when the failure looks like a permissions problem (usually: the GitHub App
is not installed on upstream_repo).
A deployment can set github.issue_token to a service-account PAT to file everything under one bot
account instead. That is not recommended and it is off by default: it erases the attribution
that is the point of the design. Use it only where an org policy forbids filing as users.
All errors share one shape:
{ "error": { "code": "invalid_state", "message": "Human-readable description", "details": {} } }Common codes: unauthorized (401), session_not_found (404), invalid_state (409),
invalid_request (400), rate_limited (429, carries Retry-After — see §3.2),
upload_too_large (413).
A run that fails reports why in the error field of GET /v1/sessions/{id}. One worth
recognizing:
openrouter: response hit the 32000-token output ceiling and was truncated
(31998 chars returned). Raise providers.openrouter.max_tokens.
The model stopped at the output ceiling rather than at the end of its answer, so the HTML it
returned is cut mid-tag. Iris fails the run instead of assembling the fragment — a truncated
page still parses, so it would otherwise be delivered as though the missing content were never in
the source. Raise max_tokens on that provider block and re-run. Dense full-page tables and
forms are the usual trigger.
./test/e2e.sh # boots mocks + Iris, runs all of the above via curl, asserts each stepThis is the same script CI runs on every PR, and its output is handed to the automated reviewer as evidence rather than re-run by it (PRD §7.14, and Automated code review). So a change that breaks a request or response documented above surfaces as a blocking review finding quoting the failure — which is why this file is in the reviewer's scope: the API docs are part of the contract, and docs that now contradict the code are treated as a real defect.