- A point — a single event (
target_event_id), or the run as a whole when you omit the target. - A range — a
[start, end]wall-clock window within a run.
Register a schema first
A schema is a named, versioned JSON Schema (draft 2020-12) that ingest validates annotation payloads against. Register one before you annotate against it:- Names are organization-global, not project-local — every project and workstream in the organization
shares one namespace. Namespace your schema names per workstream (
kaggle.experiment.v1, notexperiment.v1): re-registering a name another workstream already uses doesn’t error — it silently creates that schema’s next version for everyone reading it. - An unseen name starts at version 1.
- Re-registering an existing name creates the next version — after a backward-compatibility check. An incompatible change is rejected rather than silently breaking existing annotations.
hiloop annotation-schema archive eval.qualityretires a version without deleting it; annotations already stamped against it stay valid.hiloop annotation-schema listshows the latest live version per name (add--include-archivedfor every version);get <name> [--version N]fetches one.
Promote the fields you query
Any field stays queryable from the JSON payload. To make a field fast to filter, sort, or join on across many runs, promote it into a typed column at register time with--promote field:type[:identity][:bloom] (type is str / f64 / i64 / bool):
- A promoted field gets full columnar acceleration (statistics, page-index pruning, predicate pushdown) for the “compute once, filter forever” query — without baking any field into the platform. Unpromoted fields stay queryable from the payload, just without the column speedup.
:identitymarks a field part of the latest-wins key (see Re-scoring). Mark anannotatorfieldidentityto keep the latest write per annotator. Distinct records need an identity field: without one, every write to the same target supersedes the last, so rows you meant to coexist (one per annotator, lane, or segment) collapse to the newest on the default read. Registration warns when a schema promotes fields but declares no identity field.:bloomadds a point-lookup index (string fields only) — useful for a high-cardinality promoted id you look up by exact match.- Registration also auto-creates the schema-named views (here
ann_eval_quality, plus its full-history siblingann_eval_quality_history) that surface each promoted field under its declared name — see Query annotations back.
Annotate a run or an event
Attach a judgment to a run — or to one event within it with--target-event. There are two ways
in, and they write the same annotation event:
- The CLI (
hiloop annotations add) sends through the telemetry ingest path — the ergonomic option. - The
AnnotateAPI is the typed call you make from your code.
--data; there are no special value flags. The
payload is used verbatim — nested objects and arrays are preserved — and --data accepts inline
JSON, @file, or - for stdin:
metric{} block and an array of evidence event
ids — goes through the same flag:
score/outcome/annotator, those values lift into typed columns for fast
filter/sort while staying in the payload. Both examples anchor the annotation at the run you pass
as --run — an annotation always belongs to exactly one run and inherits that run’s logical
lineage position. The sandbox runtime does not inject a sandbox credential or HILOOP_RUN_ID;
annotate from an authenticated external client. --output json prints the minted
{"event_id": …}.
By default the server mints a fresh event_id per invocation, so re-running the command writes a
new annotation. To make a retry safe after an ambiguous failure (a 5xx, a lost response), mint the
id yourself with --event-id <ULID>: re-running with the same id returns the stored annotation
instead of writing a duplicate. The id names this logical annotation — never reuse it for
different content.
The same annotation through the API, which anchors by run_id:
event_id — the annotation’s stable dedup and lookup key. The
Annotate request body is:
Annotate a range
A range annotation targets a window instead of a single event — useful for “this whole retry loop was slow” or “the agent went off-task between these timestamps”. Pass--range <start>..<end>,
where both endpoints are times — an RFC 3339 timestamp or a raw wall-clock nanosecond value
(exactly what a query’s ts_wall_ns column returns) — or both are event ids, in which case the
window spans those two events’ recorded timestamps:
AnnotateRange API is the same call, with range_start_ns / range_end_ns (or
range_start_event_id / range_end_event_id) in place of target_event_id.
Annotate a project
A project-scoped annotation has no run at all: it belongs to the project itself. It is the home for cross-run knowledge — promotion decisions, negative results, claims about the problem — that must survive every sandbox and run. Pass--project (slug or id) instead of --run:
--target-event/--range don’t apply);
they are queryable like everything else, including through their schema-named view. Through the
API, set project_id instead of run_id.
Re-scoring
Annotations are append-only and immutable: re-scoring writes a new event with a later timestamp. By default a read returns the latest annotation per target and schema — the target is what you annotated: the run, the event, or the range (its exact bounds) — so a re-score of the same target supersedes the prior one, while annotations on different targets (say, two different ranges of the same run) each stay current. Declaring anidentity field (e.g. annotator) refines that key so the
latest write per annotator is kept — a human correction and an LLM judge on the same event both
survive, each at their latest. Identity is keyed by the field’s value in each record’s payload,
so it applies across schema versions: records written before a version bump that declared the field
fold into the same latest-wins chain as post-bump writes. Nothing is mutated or hidden: every
version stays readable.
Distinct records need an identity field — or every write supersedes the last. With no identity
field declared, the supersession key is only the annotated target, so two same-schema writes to the
same run (or event, or range) are treated as versions of one record and the default read serves
just the newest. If your rows are separate records — per-worker results, append-only measurements —
declare the field(s) that tell them apart as
identity (a lane, a segment, an annotator) so
they coexist. Superseded rows are never lost: the default listing reports how many it hid, and
--history returns them all — in SQL, through the ann_<schema>_history view.List annotations
hiloop annotations list (or GET /v1/telemetry/annotations) is the served read implementing
those semantics:
run_id, its lineage position; both absent on a
run-less project annotation), the schema name, the target (target_event_id, or the range
bounds plus the endpoint event ids for an event-bounded range), the writing principal,
ts_wall_ns, and your payload under payload_json. On the HTTP API the payload is a raw JSON
object string — the exact bytes you annotated, so payload values of every JSON type (including
64-bit integers, which plain JSON numbers cannot carry past 2^53) read back unchanged. The CLI’s
--output json prints the full {"annotations": […]} envelope with each payload spliced back in
as a real nested JSON object (same bytes, same fidelity — no double-decoding needed):
--history, each JSON row also carries a status field — "current" or "superseded" —
the same marking the table’s STATUS column shows, so scripts never re-derive latest-wins
themselves.
status is per supersession key, not per payload value. A row reads superseded when a newer
write shares its whole key: the anchor scope, the schema, and the target, refined by the schema’s
declared identity fields. If the schema declares no identity field, every same-scope write
belongs to one chain, so a standing row can read superseded even though no newer row carries its
distinguishing payload value (a fingerprint, a lane): the write that superseded it is simply a
different record in the same chain. Before treating status as a per-record fact (for example,
verifying a standing registry row is still current before acting on it), make sure the field that
names the record is declared :identity on the schema. The declaration applies retroactively:
identity is keyed by each row’s stored payload value, so existing rows re-read under the refined
key without a rewrite.note: N superseded annotation row(s) hidden … use --history on
stderr, and the API response carries the count as superseded_count.
Change a few fields of a record: read-modify-resend
A re-annotation replaces the payload wholesale — there is no partial update. When a record moves through states (a queue item goingqueued → submitting → scored, say) and a transition changes
only a couple of fields, read the record’s current version, merge your changes into it, and resend
the whole payload, so every untouched field carries forward instead of silently dropping:
--run in both commands). Because annotations are
append-only, the pre-transition version stays in --history — a bad merge is always recoverable
from the prior version.
Query annotations back
Registering a schema auto-creates a schema-named view:eval.quality is queryable as the table
ann_eval_quality (the name is the schema name lowercased, with every non-alphanumeric run mapped
to _, behind an ann_ prefix). The view serves the current annotations — the same
latest-wins read as annotations list: the newest version per annotated target, refined by the
schema’s declared identity fields — so SQL and the served listing agree about what the registry
contains. Every stored version, superseded ones included, stays queryable through the full-history
sibling ann_eval_quality_history — the SQL counterpart of --history. Registration alone creates
both; a new schema version refreshes them, and archiving the schema retires them.
A view’s payload-derived columns are the promoted fields — each under its declared name, backed
by its typed column, so filters and aggregates get the full columnar acceleration. Alongside them
every view carries the annotation’s context columns (event_id, run_id, root_run_id,
lineage_path, project_id, principal, ts_wall_ns, and the point/range target columns) and a
payload_json column holding the whole payload as JSON text. A field you didn’t promote is not a
column — selecting it is an unknown-column error — but it stays reachable through the payload
(promote it if you filter or aggregate on it often):
GROUP BY \"group\"), and one
that collides with a context column (or payload_json) is surfaced as field_<name>. The ann_
prefix is reserved: you can’t store your own data view under it.
Annotations are still ordinary events, so the raw form works too — filter events by
signal = 'annotation' and the schema name. The raw attributes_json carries your payload plus
platform-stamped metadata; reach into it with hiloop_json_get(attributes_json, '$.path.to.key'):
Next
- Query telemetry — the SQL query that reads annotations back.
- Compare runs — compare annotated outcomes with SQL.
- Saved & data views — save an annotation query as a reusable view.