Annotation queues
Scoring the trace you happen to be reading is one thing. A review programme is the other way round: somebody decides which traces deserve a human verdict, several people work through them, and the team can see what is done and what is left.
Two nouns:
- A queue is a named list and the score names a reviewer must set on
every item in it. The names are score configs — the same ones an eval
harness declares (see scores.md) — so a queue and
a chart never disagree about what
accuracymeans. - An item is one trace, or one observation of a trace. In an agent trace the thing to judge is often one generation, so an item can point at it.
The verdicts themselves are not stored here. They are scores, written through the endpoint everything else writes scores through, and completing an item is the server checking that the scores the queue asked for are actually on the target. Delete a queue and the scores stay: they are the work, and the queue was only the list of what to do.
Everything below is a plain JSON API that works with curl, and every step
has a CLI command and a screen.
Endpoints
| Method | Path | Purpose | A key needs |
|---|---|---|---|
GET |
/api/v1/queues |
Every queue with its progress | read |
PUT |
/api/v1/queues/{name} |
Create a queue or replace it whole | write |
GET |
/api/v1/queues/{name} |
One queue: its score names and counts | read |
DELETE |
/api/v1/queues/{name} |
Delete it with its items; dry run until ?confirm= |
write |
POST |
/api/v1/queues/{name}/items |
Add one target or an array of them | write |
POST |
/api/v1/queues/{name}/items/from-traces |
Add every trace a listing filter matches, capped | write |
GET |
/api/v1/queues/{name}/items |
The items oldest first, filtered and paginated | read |
GET |
/api/v1/queues/{name}/next |
The next item to annotate, claimed | write |
GET |
/api/v1/queues/{name}/items/{id} |
One item | read |
POST |
/api/v1/queues/{name}/items/{id}/complete |
Mark it done | write |
POST |
/api/v1/queues/{name}/items/{id}/skip |
Mark it skipped, with the reason | write |
POST |
/api/v1/queues/{name}/items/{id}/reopen |
Back to pending — or just release the claim | write |
DELETE |
/api/v1/queues/{name}/items/{id} |
Take one item out of the list | write |
Authentication is the same as everywhere: Authorization: Bearer <secret key>
(see ingest.md). The last column is the
scope a key must hold: next is write, because it claims
the item it hands out, and the verdicts themselves are scores, which a key
writes with ingest.
Declaring a queue
A queue is declarative, like a score config: the PUT is the whole queue, and
sending the same body twice writes nothing. Keep it in a script beside the
configs it names.
curl -X PUT -H "Authorization: Bearer tp-sk-…" \
http://localhost:4318/api/v1/queues/weekly-review -d '{
"description": "Did support answer the question, and how did it sound?",
"score_configs": ["accuracy", "tone"]
}'
{
"name": "weekly-review",
"description": "Did support answer the question, and how did it sound?",
"score_configs": ["accuracy", "tone"],
"counts": {"pending": 0, "completed": 0, "skipped": 0},
"created_at": "2026-09-08T09:00:00Z",
"updated_at": "2026-09-08T09:00:00Z"
}
201 on create, 200 on a replace or a no-op. Every name in score_configs
must already be a declared config, or the call is a 400 naming the missing
one — a queue that asked for a name nothing defines would be a form nobody can
fill.
The list can be changed later, and it then governs the items that are not yet completed. Nothing re-validates what is already done.
The name follows the prompt grammar: one URL path segment, starting with a letter or a digit, at most 200 characters.
Filling it
One target at a time
curl -H "Authorization: Bearer tp-sk-…" \
http://localhost:4318/api/v1/queues/weekly-review/items \
-d '{"trace_id": "4f8c1d2e3a5b6c7d8e9f0a1b2c3d4e5f"}'
An array works too, all-or-nothing, up to 1,000 targets: an add of more is a
400 (an add takes at most 1000 targets; use items/from-traces for a filter),
refused before any target is looked at, and a filter is what from-traces is
for. Adding a target the queue already holds answers with the item it already
is and counts it as existing, whatever its status — which is what makes a
script safe to retry. observation_id beside trace_id queues that step
rather than the whole run.
The target does not have to exist yet: an item may be queued for a trace whose spans are still in flight, exactly as a score may be written about one.
Everything a filter matches
curl -H "Authorization: Bearer tp-sk-…" -X POST \
"http://localhost:4318/api/v1/queues/weekly-review/items/from-traces?status=error&environment=production&limit=200"
The query parameters are the trace listing's own — the same names, the
same meanings, the same 400 on one that does not exist (see
api.md). "Queue what I am looking at" is one call with no
second grammar.
The newest limit matches are added (1–1000, 100 by default). matched is
how many there were, counted exactly; capped says more matched than were
taken. To continue, run it again with to= at the oldest one you took — the
window is half-open, so nothing is queued twice, and the dedupe above would
catch it anyway.
With no filter at all it takes the newest limit traces of the project.
Annotating
Taking an item
curl -H "Authorization: Bearer tp-sk-…" \
"http://localhost:4318/api/v1/queues/weekly-review/next?annotator=ada"
{
"item": {
"id": "c0ffee00c0ffee00c0ffee00c0ffee00",
"trace_id": "4f8c1d2e3a5b6c7d8e9f0a1b2c3d4e5f",
"status": "pending",
"seq": 1,
"added_at": "2026-09-08T09:01:00Z",
"claimed_by": "ada",
"claimed_until": "2026-09-08T09:11:00Z"
},
"pending": 200
}
Being handed an item claims it for ten minutes, so two people at one desk do not review one trace twice by accident. The window is a constant, not a setting.
Asking again gives you the same item back: a reload must not hand a reviewer a
different trace mid-verdict. Somebody else asking gets the next one nobody
holds. When nothing is claimable the answer is {"item": null, "pending": N},
and pending is then what other people are holding — come back, the claims
expire.
Who is asking depends on the credential. A program working the queue with
a key sends annotator, a name of 1–200 characters: it has a name and no
account, and the name is what holds its claim and what claimed_by and
completed_by say. A person signed in to the web interface sends nothing —
the server holds their claim and their completion by their account, and a
session that sends annotator is refused with a 400 rather than having it
quietly dropped. So two people with the same display name are two reviewers,
and renaming yourself mid-item keeps the item in your hands.
For an account, claimed_by_account and completed_by_account carry its id,
and claimed_by and completed_by name it for whoever reads: its display
name, else its email for an editor or an owner, else a member — the rule the
author of a score follows — or
a deleted account once it is gone. claimed_by_standing and
completed_by_standing say where that account stands in the project now — a
role, or removed, disabled, deleted — and the queue page mutes one that is
gone. A second completion's 409 names the first finisher the same way, for
whoever it refuses.
Work a desk did before the server held reviewers by account stays under the
name it typed: annotator= finds it, account= does not, and a claim that was
in flight when the server was upgraded simply expires within its ten minutes.
Posting the verdict
The verdict is a score, written exactly as any other (see scores.md). The interface stamps where it came from, and a script should too:
curl -H "Authorization: Bearer tp-sk-…" http://localhost:4318/api/v1/scores -d '{
"trace_id": "4f8c1d2e3a5b6c7d8e9f0a1b2c3d4e5f",
"name": "accuracy",
"value": 0.9,
"metadata": {"source": "annotation", "queue": "weekly-review", "annotator": "ada"}
}'
Completing
curl -H "Authorization: Bearer tp-sk-…" \
http://localhost:4318/api/v1/queues/weekly-review/items/c0ffee00c0ffee00c0ffee00c0ffee00/complete \
-d '{"annotator": "ada"}'
The queue promised a shape, and completed means the shape was filled. The server checks it against the scores actually stored:
— 409, and nothing changes. Post the missing score and try again.
Two rules are worth stating:
- Whoever wrote it counts. A judge's verdict already on the trace is a verdict; the reviewer confirms or edits it rather than repeating it. The desk prefills the form from exactly those scores.
- The target has to match. A trace item needs scores that name no observation; an observation item needs scores that name its observation. They are different verdicts about different things.
A second completion is a 409 naming who got there first, so a race between
two reviewers is visible to the one who lost it.
Skipping, reopening, releasing
curl … /items/{id}/skip -d '{"annotator": "ada", "reason": "not a support conversation"}'
curl … /items/{id}/reopen -d '{"annotator": "ada"}'
Not every trace deserves a verdict, and the reason is what makes that readable
afterwards. Skipping an item somebody has already completed is a 409
naming them: a skip writes the same columns a completion filled, so it would
destroy the record of who decided what rather than add to it. Reopen it first.
reopen puts a completed or skipped item back to pending — for a manager who
disagrees with a verdict, or wants a skip looked at again; the scores stay
where they are, because reopening asks for another look and not for a
retraction.
reopen on an item that is already pending is not refused: what it does to
one is release the claim. That is how the desk's Later puts an item back
without waiting out the ten minutes.
Reading the results
The queue page is the bookkeeping — who completed what, what was skipped and why:
curl -H "Authorization: Bearer tp-sk-…" \
"http://localhost:4318/api/v1/queues/weekly-review/items?status=completed&annotator=ada"
Items come back oldest first, in the order they were added, which is the order
they are worked in. ?status=, ?annotator= and ?account= narrow it;
paging is the same keyset both ways as every other listing
(api.md).
annotator= is "what has this program got", by the name it sends: the items
it completed or skipped, and the pending ones it is holding a claim on — one
that has not expired, since an expired claim is anybody's next. So
?status=pending&annotator=ada is ada's desk right now, and
?status=completed&annotator=ada is her work. account= asks the same of a
signed-in person, by account id — account=me is the account asking, which a
key does not have.
The verdicts are read where every score is read — filtered by name, by target, or by time:
curl -H "Authorization: Bearer tp-sk-…" \
"http://localhost:4318/api/v1/scores?name=accuracy&trace_id=4f8c…"
A score written from the desk carries metadata.queue, and its
author is the account that wrote it, so
"everything ada decided in the weekly review" is a filter over the scores you
already have (?author=) — and the chip on the trace says annotation where
a judge's says api and a reader's says web. A script can stamp its own
metadata.annotator, as the example above does; the server reads nothing
from it.
The same loop from the command line
The key this runs with holds ingest (the scores), read and write (the
queue).
echo '{"data_type": "numeric", "direction": "higher", "min": 0, "max": 1}' > accuracy.json
echo '{"data_type": "categorical", "categories": ["warm", "neutral", "curt"]}' > tone.json
tracepad score-configs push accuracy --file accuracy.json
tracepad score-configs push tone --file tone.json
tracepad queues put weekly-review --config accuracy --config tone \
--description "Did support answer the question, and how did it sound?"
# Fill it: one trace, or everything a filter matches.
tracepad queues add weekly-review --trace 4f8c1d2e3a5b6c7d8e9f0a1b2c3d4e5f
tracepad queues add weekly-review --from-traces --error --env production --limit 200
# Work it. `next` answers with the whole item, so one call gives both ids.
NEXT=$(tracepad queues next weekly-review --annotator ada --json)
ITEM=$(echo "$NEXT" | jq -r .item.id)
TRACE=$(echo "$NEXT" | jq -r .item.trace_id)
tracepad scores add --trace "$TRACE" --name accuracy --value 0.9
tracepad scores add --trace "$TRACE" --name tone --type categorical --string warm
tracepad queues complete weekly-review "$ITEM" --annotator ada
# Or not.
tracepad queues skip weekly-review "$ITEM" --annotator ada --reason "not a support conversation"
# The bookkeeping.
tracepad queues ls
tracepad queues items weekly-review --status completed
The same loop with a judge model in place of ada is the same commands: the
CLI can do nothing the API cannot, and the API can do nothing the desk cannot.
See cli.md.
The same loop from the web interface
Evals → Queues. A queue's page shows what is done, by whom and what was skipped; Start annotating opens the desk, which is the loop above with nothing to type: the trace on the left, one control per score on the right, Complete & next.
Traces reach a queue from two places — the Add to queue button on a trace header or an observation panel, and Add to queue… beside the filter bar on Traces, which queues what the current filters match. See ui.md.
What happens to items
- Items follow their trace. The retention sweep that deletes a trace
deletes its items in the same job, erasing a user's data
(retention.md) takes the items of their traces, and so does
deleting a trace by hand — one by id, or a filter's worth
(admin.md). An item is a pointer, and a pointer
to a deleted trace is a desk showing an empty page. Every one of these
destructive endpoints counts what it takes under
annotation_itemsin the dry run and in the answer, and deleting a project names the queues themselves too (admin.md). A trace deleted from the desk itself takes its item with it, and the desk moves on to the next. - Deleting a queue takes its items and nothing else. The scores written while annotating stay on their traces and expire on their own timestamps, like every other score.
- Deleting a score config a queue names touches neither the queue nor the
scores. The queue keeps asking for that name, and the desk shows it as a
free-typed score with a note;
PUTthe queue again to drop it.
Edge cases
- An item whose trace has not arrived. The desk shows the trace page's
not-found state, with Skip and Later available.
completeis a409listing every name, because none of them can be on a trace that is not there. - An observation id that is not in the trace. The desk shows the trace with
a note; scores go to the observation id as given, exactly as
POST /api/v1/scoreswould take them. - A claim that expires while the form is open. The save still posts the
scores — they are scores — and
completestill succeeds if nobody else completed the item meanwhile. The claim gatesnext, not completion. - Two queues over the same trace. Independent items, shared scores. So completing the second may need nothing new: the desk prefills and Complete is enabled at once.