inklet docs

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.

StageTypes
Acceptedanalysis.created · analysis.dispatched · analysis.leased · analysis.lease_expired
Workingcontext.materialized · agent.activity
Planningplan.submitted · plan.rejected · plan.accepted
Resultrender.finished · render.failed · delivery.published · delivery.confirmed · delivery.failed
Finishedanalysis.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);
}
Typedata
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

Prop

Type

reasonWhat the plan got wrong
targetAimed at the wrong display, or at one outside the allowed set.
layout_mismatchNamed no template, an unknown one, or an internal one.
content_refsLeft a named Content unused, or referenced one it should not.
schemaThe layout parameters failed the template's schema.
otherEverything 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.

Prop

Type

Prop

Type

chosen: null means the agent looked at layouts but has not settled on one. 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): string

One 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.

Values added after your SDK version still get a line: an activity kind it does not know is described from its step count, like other; an activity state it does not know reads as still running; and a rejection reason it does not know gets the generic "sent back" sentence. The same goes for source and level on every event — a value this SDK has not seen arrives as its own string.

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.

OptionDefaultNotes
after—Resume after this seq instead of replaying from the beginning.
signal—Aborting throws OperationAbortedError.
pollIntervalMs1000Only used by the polling fallback.
reconnectDelayMs500Doubled per attempt, five attempts maximum.
timeoutMsclient'sHow long each connection attempt may take to answer. It never cuts off a stream that has started — events can be minutes apart.

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; timeoutMs bounds each page request. Up to three failed page reads in a row — a dropped connection, a timeout, or HTTP 408, 429, 500, 502, 503, or 504 — are retried with back-off. It works on a running Analysis as well as a finished one.

listEvents() — one page

listEvents(analysisId: string, options?: ListAnalysisEventsOptions): Promise<AnalysisEventPage>

Prop

Type

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 seq with Last-Event-ID when the connection drops, or when reconnecting is answered with 408, 429, 500, 502, 503, or 504 — five attempts, exponential back-off capped at 30 seconds, never sooner than Retry-After — 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 polling listEvents() every pollIntervalMs until the Analysis is terminal.
  • signal stops it with OperationAbortedError, even mid-read of an open stream. Breaking out of the for await loop 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.

On this page