# Analysis events (/api/events)



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.

```ts
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));
}
```

<Callout>
  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.
</Callout>

## The event types [#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 [#the-envelope]

Every event carries the same fields, whatever its type.

```ts
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.

<Callout type="warn">
  `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.
</Callout>

## Payloads [#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.

```ts
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.

<Callout>
  `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.
</Callout>

### `context.materialized` [#contextmaterialized]

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` [#planrejected]

```ts
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` [#analysislease_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` [#agentactivity]

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.

```ts
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 &#x2A;*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 [#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()`:

```ts
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.

<Callout type="warn">
  `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}` ``.
</Callout>

## `describeEvent()` [#describeevent]

```ts
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 [#reading-the-stream]

<div className="fd-steps [&_h3]:fd-step">
  ### `watch()` — live [#watch--live]

  ```ts
  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--after-the-fact]

  ```ts
  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--one-page]

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

  ```ts
  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.
</div>

<Callout type="warn">
  `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()`.
</Callout>

## Transport [#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 [#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:

```ts
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.
