Analysis events
The public inklet Analysis event stream — the closed set of event types, their payloads, and how to follow a run live with watch, timeline, and listEvents.
An Analysis publishes an ordered stream of events: what the agent was given, what it is working on, what it planned, and how the result was rendered and delivered.
import { describeEvent } from "@inklethq/sdk";
const analysis = await inklet.analyze({ contentIds: [content.id] });
for await (const event of inklet.analyses.watch(analysis.id)) {
console.log(describeEvent(event));
}The stream is a progress report, not the run's log. It answers "where is this now", not "how is it doing it". The agent's turns, its individual tool calls and their arguments and output, and the sentences a rejected plan was faulted for all stay inside inklet. They are the run's implementation — they name internal paths and change whenever the agent does — so no public field exposes them, at any depth.
The event types
A public reader returns exactly these sixteen types, and nothing else.
| Stage | Types |
|---|---|
| Accepted | analysis.created · analysis.dispatched · analysis.leased · analysis.lease_expired |
| Working | context.materialized · agent.activity |
| Planning | plan.submitted · plan.rejected · plan.accepted |
| Result | render.finished · render.failed · delivery.published · delivery.confirmed · delivery.failed |
| Finished | analysis.completed · analysis.failed |
AnalysisEventType is that closed union widened with (string & {}), so a
type this SDK has never seen still parses rather than failing the stream. A
backend that starts publishing a new type does not break an older SDK.
The envelope
Every event carries the same fields, whatever its type.
type AnalysisEvent = {
seq: number;
at: string;
/** Which execution attempt produced the event; a retry restarts the agent. */
attempt: number;
source: "agent" | "backend";
type: AnalysisEventType;
level: "info" | "warn" | "error";
/** A displayable one-line English summary, generated by the service. */
summary: string;
/** Structured, type-specific fields. Empty when the event carries none. */
data: Record<string, unknown>;
};at is always UTC with millisecond precision — 2026-09-14T10:00:05.120Z.
summary is English; there is no locale option, and no field carries the
agent's own text.
seq is monotonic and is what you pass back as after to resume — but it is
not contiguous. The sequence is shared with the run's internal events,
which a public read never returns, so a public stream skips numbers. Treat it
as an ordering and a resume token, never as a count or an index.
Payloads
data is typed per event type. type is open, so TypeScript cannot use it as
a discriminant on its own; isAnalysisEvent() narrows the event and its
payload together.
import { isAnalysisEvent } from "@inklethq/sdk";
if (isAnalysisEvent(event, "plan.accepted")) {
console.log(event.data.presentationIds);
}| Type | data |
|---|---|
analysis.created | { mode, trigger, context, contentCount, hasTarget } |
analysis.dispatched | { topic, position, lane? } |
analysis.leased | { attempt, leaseTtlSeconds } |
analysis.lease_expired | { attempt, maxAttempts } |
analysis.completed | { outcome, presentations, turns, inputTokens, outputTokens } |
analysis.failed | { code, message, source, stage? } — code is the same one Analysis.failure.code carries. |
context.materialized | { contents, history, displays, templates, bytes, vision, warnings? } |
agent.activity | { activityId, kind, state, steps, stats } |
plan.submitted | { round, outcome?, actions?, failure?, code? } |
plan.rejected | { problems, reason, attempt } |
plan.accepted | { presentationIds, actions, skipped } |
render.finished | { presentationId, renderTaskId }, plus format, width, and height for a generated Presentation |
render.failed | { presentationId, renderTaskId, code, message } |
delivery.published | { presentationId, displayId, placement } |
delivery.confirmed · delivery.failed | { presentationId, displayId } |
The SDK types the fields each event is defined to carry and passes every
other field through untouched, so a payload that gains a field still reads.
analysis.created, analysis.dispatched, and analysis.leased are typed as
open records — the fields above are what the service sends today, not a shape
the SDK narrows for you.
analysis.completed currently reports turns, inputTokens, and
outputTokens alongside the outcome. They are the run's cost, not its
result; outcome and presentations are the two fields worth branching on.
context.materialized
Counts of what the agent was given to work with: contents named by the
Analysis, history retrieved from the user's earlier Contents, displays,
and templates — the layouts available to choose from. bytes is the size of
the materialized workspace, vision is true when at least one image was
included, and warnings is present, with level: "warn", when something was
degraded.
plan.rejected
interface PlanRejectedData {
/** How many problems were found, not what they were. */
problems: number;
reason: "layout_mismatch" | "target" | "content_refs" | "schema" | "other";
attempt: number;
}reason | What the plan got wrong |
|---|---|
target | Aimed at the wrong display, or at one outside the allowed set. |
layout_mismatch | Named no template, an unknown one, or an internal one. |
content_refs | Left a named Content unused, or referenced one it should not. |
schema | The layout parameters failed the template's schema. |
other | Everything else — a duplicate action, or too many of them. |
A rejected plan is not a failed run. The agent corrects it and submits
again, and the Analysis never leaves running. The problem sentences
themselves are internal; the count and the reason are the public shape, and
reason describes the first problem found.
analysis.lease_expired
A worker's lease ran out before it returned a result. This reads like the end
and is not: the attempt is abandoned, the run is handed back out, and the agent
starts again from the top. The event is warn, the Analysis stays running,
and the next analysis.leased carries attempt + 1. Two of these followed by
another analysis.leased means "retried twice, still going" — not a run to
tell anyone to retry.
agent.activity
A run of related agent steps arrives as one activity rather than one event per step. This is the public shape of agent progress; individual tool calls are not published.
interface AgentActivityData {
/** Stable for the life of one activity, unique within an `attempt`. */
activityId: string;
kind:
| "reading_brief"
| "reading_notes"
| "checking_display"
| "choosing_layout"
| "submitting_plan"
| "retrying"
| "other";
state: "active" | "done" | "failed";
/** Steps folded into this activity so far. */
steps: number;
stats: AgentActivityStats;
}
interface AgentActivityStats {
notesRead?: number;
layoutsSeen?: number;
/** The layout the agent settled on; `null` means "looked, not settled". */
chosen?: string | null;
failedSteps?: number;
/** Steps the workspace guard refused. */
deniedSteps?: number;
}Every stats field is absent rather than 0 when it does not apply:
"read no notes" and "this kind of activity does not count notes" are two
different statements.
Merging
The same activity is re-emitted as it progresses — throttled active updates,
then a final done or failed — so a raw list renders the same row over and
over. Upsert by attempt and activityId together, or hand the list to
mergeActivities():
import { describeEvent, mergeActivities } from "@inklethq/sdk";
const rows = mergeActivities(events).map(describeEvent);
// "Ready · 3 Contents, 1 Display, 9 layouts"
// "Read 4 notes"
// "Chose Daily Summary"
// "Submitted the plan · 2 actions"Each activity keeps the position of its first appearance, so the order still reads as the order things started, and every other event passes through untouched.
activityId is only unique within an attempt. It restarts at a1 every
time the agent loop restarts, so a retried run has one a1 per attempt.
Keying on the id alone folds attempt two's first activity into attempt one's
row and the retry disappears from the timeline. The key is the pair:
`${event.attempt}:${event.data.activityId}`.
describeEvent()
describeEvent(event: AnalysisEvent): stringOne English line, read from type and data, falling back to the backend's
summary when there is nothing better to say. agent.activity is the reason
it exists: it is the one public type the backend writes no sentence for,
because what it means depends on counters that change while the activity runs.
It is pure and English-only, and nothing in the SDK branches on the strings it returns — they are display copy, and they change.
Reading the stream
watch() — live
watch(analysisId: string, options?: WatchAnalysisOptions): AsyncIterable<AnalysisEvent>Follows the run and ends on its own once the Analysis is completed or
failed.
| Option | Default | Notes |
|---|---|---|
after | — | Resume after this seq instead of replaying from the beginning. |
signal | — | Aborting throws OperationAbortedError. |
pollIntervalMs | 1000 | Only used by the polling fallback. |
reconnectDelayMs | 500 | Doubled per attempt, five attempts maximum. |
timeline() — after the fact
timeline(analysisId: string, options?: TimelineOptions): AsyncIterable<AnalysisEvent>The same events, paged for you. pageSize defaults to 100 and accepts 1–200;
after starts past a seq you already have. It works on a running Analysis as
well as a finished one.
listEvents() — one page
listEvents(analysisId: string, options?: ListAnalysisEventsOptions): Promise<AnalysisEventPage>interface AnalysisEventPage {
items: readonly AnalysisEvent[];
/** Pass back as `after` to read the next page; `null` at the end. */
nextAfter: number | null;
hasMore: boolean;
/** The Analysis state when the page was produced. */
state: AnalysisState;
}after is an exclusive lower bound on seq and accepts 0 or more; limit
accepts 1–200 and defaults to 50. A limit outside that range is a 400, not
a silent clamp.
watch() ends when the Analysis reaches a terminal state — and rendering and
delivery happen after that. So render.* and delivery.* events are not
seen on the live stream; read them afterwards with timeline() or
listEvents().
Transport
watch() handles the transport, and you receive the same events either way:
- It reads server-sent events and resumes from the last
seqwithLast-Event-IDwhen the connection drops — five attempts, exponential back-off capped at 30 seconds — so nothing is lost across a reconnect. - If the response is not
text/event-stream, which is what an intermediate proxy that cannot carry streaming responses returns, it falls back to pollinglistEvents()everypollIntervalMsuntil the Analysis is terminal. - Breaking out of the
for awaitloop closes the connection.
Relaying to a browser
The SDK is server-only: a personal access token must never reach a browser.
Run watch() on your server and relay events to the client over your own
channel.
Merge on whichever side owns the list — on the browser side that is an upsert
keyed by attempt and activityId, so a late active update never appends a
second row and a retry never overwrites the attempt before it:
function apply(rows: Map<string, string>, event: AnalysisEvent) {
const key = isAnalysisEvent(event, "agent.activity")
? `${event.attempt}:${event.data.activityId}`
: String(event.seq);
rows.set(key, describeEvent(event));
}In browser code, read that relay with fetch plus ReadableStream rather than
EventSource. EventSource cannot set request headers, cannot send a body,
and gives you no access to the response status or content type — so it cannot
tell a real stream from one a proxy has downgraded, which is exactly the case
the fallback above exists for.