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

interface PlanRejectedData {
  /** How many problems were found, not what they were. */
  problems: number;
  reason: "layout_mismatch" | "target" | "content_refs" | "schema" | "other";
  attempt: number;
}
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.

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): 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.

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
afterResume after this seq instead of replaying from the beginning.
signalAborting throws OperationAbortedError.
pollIntervalMs1000Only used by the polling fallback.
reconnectDelayMs500Doubled 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 seq with Last-Event-ID when 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 polling listEvents() every pollIntervalMs until the Analysis is terminal.
  • 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