Annotations & evals
Annotations are the extension point in a transcript. Parts are Axle's vocabulary; annotations are yours. If you want something rendered next to a turn that isn't text, thinking, or a tool call — an eval score, a sandbox booting, a deploy status — this is the mechanism.
The running example here is an eval pipeline: after each agent turn, score the output on a few dimensions and show the results inline. It's the pattern Sunnyday uses in production.
The shape
import type { Annotation } from "@fifthrevision/axle/ui";
type EvalAnnotation = Annotation<
{ score: "pass" | "fail"; rationale: string },
"objectives_eval"
>;
type SandboxAnnotation = Annotation<{ image: string }, "sandbox">;
type AppAnnotation = EvalAnnotation | SandboxAnnotation;Parameterize the transcript with your union and it flows everywhere:
import { Transcript } from "@fifthrevision/axle/ui";
import type { TurnEvent } from "@fifthrevision/axle/ui";
const transcript = new Transcript<AppAnnotation>();
// However your app publishes events — a channel, a socket, a local callback.
declare function emit(event: TurnEvent<AppAnnotation>): void;Now turn.annotations is AppAnnotation[], and a switch on kind in your renderer is exhaustive and type-checked.
Wrapping async work
Most annotations are asynchronous — you kick off an eval, it takes a few seconds, then you have a score. That maps onto the annotation lifecycle directly: emit a running annotation, do the work, then replace it with a settled one under the same id.
This helper is worth writing once:
import type { TurnEvent } from "@fifthrevision/axle/ui";
async function withTurnAnnotation<T>(
emit: (event: TurnEvent<AppAnnotation>) => void,
turnId: string,
opts: {
kind: AppAnnotation["kind"];
label: string;
placement?: "before" | "after";
labelDone?: (result: T) => string;
},
run: () => Promise<T>,
): Promise<T | null> {
const id = crypto.randomUUID();
const base = { id, kind: opts.kind, placement: opts.placement ?? "after" };
emit({
type: "annotation:start",
target: { type: "turn", turnId },
annotation: { ...base, label: `${opts.label}…`, status: "running" },
} as TurnEvent<AppAnnotation>);
try {
const result = await run();
emit({
type: "annotation:end",
target: { type: "turn", turnId },
annotation: {
...base,
label: opts.labelDone?.(result) ?? `${opts.label}: done`,
status: "complete",
data: result,
},
} as TurnEvent<AppAnnotation>);
return result;
} catch (error) {
emit({
type: "annotation:end",
target: { type: "turn", turnId },
annotation: { ...base, label: `${opts.label}: error`, status: "error" },
} as TurnEvent<AppAnnotation>);
return null;
}
}Three things it gets right, and they're the ones easy to miss:
- The same
idthroughout. That's what makesannotation:endreplace the running annotation instead of appending a second one. - The label changes.
"Evaluating objectives…"while running becomes"Objectives: pass"when it settles. The label is the UI, so compute the final one from the result. - Failures still settle. An eval that throws emits an
errorannotation rather than leaving a spinner running forever. Swallowing the error and returningnullkeeps a failed eval from taking down the turn.
Running it after a turn
declare function evaluateObjectives(
turn: Turn<AppAnnotation>,
): Promise<{ score: "pass" | "fail"; rationale: string }>;
const result = await agent.send(userInput).final;
if (!result.ok) return;
const turnId = result.turn.id;
const objectives = await withTurnAnnotation(
emit,
turnId,
{
kind: "objectives_eval",
label: "Evaluating objectives",
labelDone: (r) => `Objectives: ${r.score}`,
},
() => evaluateObjectives(result.turn),
);result.turn.id is the anchor — that's how the annotation finds its turn.
Evals run after the send settles, out of band from the agent. They're not tools and not part of the loop, which is exactly why they need somewhere to live that isn't the conversation.
For an instant, informational annotation with no async work, emit a single annotation:start that's already complete:
emit({
type: "annotation:start",
target: { type: "turn", turnId },
annotation: {
id: crypto.randomUUID(),
kind: "objectives_eval",
label: "No deliverables to evaluate",
placement: "after",
status: "complete",
},
} as TurnEvent<AppAnnotation>);Rendering
Because label is required, one generic component handles every kind — including kinds added later:
function AnnotationView({ annotation }: { annotation: AppAnnotation }) {
const body = annotation.data != null ? JSON.stringify(annotation.data, null, 2) : null;
return (
<details>
<summary
data-status={annotation.status}
className={annotation.status === "running" ? "shimmer" : undefined}
>
{annotation.label}
</summary>
{body ? <pre>{body}</pre> : null}
</details>
);
}That's a complete, useful renderer: label, a shimmer while running, an error state, and the payload on demand. Specialize individual kinds later, when one earns a real component.
Placement is yours to honour:
function AgentTurnView({ turn }: { turn: Turn<AppAnnotation> }) {
const annotations = turn.annotations ?? [];
const before = annotations.filter((a) => a.placement !== "after");
const after = annotations.filter((a) => a.placement === "after");
return (
<div>
{before.map((a) => <AnnotationView key={a.id} annotation={a} />)}
{turn.parts.map((part) => <PartView key={part.id} part={part} />)}
{after.map((a) => <AnnotationView key={a.id} annotation={a} />)}
</div>
);
}placement defaults to "after" when accumulated, so evals naturally land below the turn they scored.
Annotating a part instead of a turn
The target can be narrower — point at one part instead of the whole turn:
declare const partId: string;
emit({
type: "annotation:start",
target: { type: "part", turnId, partId },
annotation: {
id: crypto.randomUUID(),
kind: "sandbox",
label: "Touched a protected path",
status: "complete",
},
} as TurnEvent<AppAnnotation>);Useful for flagging one specific tool call — a sandbox command that touched something sensitive, say — rather than the whole turn.
Feeding results back to the model
Annotations are display-only, so if a failed eval should change what the agent does next, that's a separate, deliberate step: send the feedback as a message.
if (objectives?.score === "fail") {
await agent.send(
`<system>Quality checks failed:\n${objectives.rationale}\nPlease address this and try again.</system>`,
).final;
}The separation is the point. The annotation is what your user sees; the message is what the model sees. Keeping them apart means you can show a rich eval panel without inflating the context window, and retry with a terse instruction without that instruction cluttering the UI.
Across a network boundary
Annotation events are plain JSON like every other TurnEvent, so evals can run on your server and render on a client with no extra plumbing:
// server — evals run here, next to your database
const event: TurnEvent<AppAnnotation> = {
type: "annotation:end",
target: { type: "turn", turnId },
annotation: {
id,
kind: "objectives_eval",
label: "Objectives: pass",
status: "complete",
data: { score: "pass", rationale: "All deliverables met." },
},
};
socket.send(JSON.stringify(event));
// client — same transcript that folds the agent's own events
transcript.apply(JSON.parse(payload) as TurnEvent<AppAnnotation>);If your transport also carries application events, apply() returns handled: false for anything it doesn't recognize — see Transcripts.