InlayDocs

The timeline

The one ordered feed of what the agent did, and how to render it.

snapshot.rows is the conversation's ordered public rendering surface. User turns, assistant turns, outputs, and tool calls/results are peers: an output produced mid-turn sits between the turns around it. Raw useInlayChat returns flat rows; a generated hook can also group them into the composite rows described below.

This is the surface for your product UI, not the operator's full call traces or private state. Use Observability for dashboard-side inspection of activations and captured requests and outcomes.

Row kinds

Every row has a type, and the compiler narrows on it. The five durable kinds are available in both raw and generated clients; composites are a generated-feed feature:

typeCarriesIsAvailability
usercontentSomething the person said.Raw and generated
assistantcontent, reasoning, completeSomething the agent said.Raw and generated
outputname, value, status, format, completeA declared output it produced.Raw and generated
tool-calltoolCallId, toolName, input, requiresClientResponseA tool it decided to call.Raw and generated
tool-resulttoolCallId, outputWhat that call returned.Raw and generated
fan-outkey, tracks, joinA supported fan-out's branch feeds.Generated only
subagentkey, rowsA subgraph's inner feed.Generated only
repeatkey, tracks, mode, done, total?A repeatable node's visible items or samples.Generated only

The generated union includes only the composite kinds supported by its metadata. The reference's Row<TName> remains the five durable kinds; FeedRow adds the three composites.

A call and its result are two rows, not one row that mutates. The call is a fact the moment it happens; whether a result ever arrives is a separate fact.

Not every tool is yours

requiresClientResponse splits the tool rows in two. A client tool (true) is a call into your app — the run pauses until you answer it (see Client tools). A server tool (false) ran and finished on the server — memory writes, web.search, an MCP connector's tool — and the row pair is there purely for observability: it never pauses, and it never appears in pendingTools.

One rendering rule to know: a server tool can be redacted — real toolName, input of "{}", an empty result. That is a private call doing its job, not a bug in your stream. See Server tools for the redaction contract.

Node-attributed rows also carry where the work happened: nodeId, and subgraphPath when it came from inside a subagent. User rows have no producing node; a composite names its source or container node.

A turn splits at an interruption

One reply does not always mean one assistant row. When the agent interrupts itself — calling a tool, or producing a streamed artifact — the turn closes and the text that follows opens a new row. So this:

"Here's a recipe:" · [produces recipe] · "hope you like it"

is three rows — assistant, output, assistant — not one bubble with an artifact stuck on the end. The output sits where it was produced, which is the whole point of an ordered feed: rendering it in array order shows what happened.

Practically, a chat UI renders consecutive assistant rows as separate bubbles, which is what you want — the artifact appears between them rather than after everything the agent said.

The prefill placeholder

For llm and grounded_answer nodes, the first assistant row exists from node start, before the model's first token. It covers prefill latency with empty content, complete: false, and a real id. Repeated item/sample rows have their own identities when their content appears, not one node-start placeholder shared across iterations.

The first content continues the same row in place — no swap, no remount — so rendering an empty assistant row as your typing indicator just works, and the bubble doesn't flicker when the text arrives. A node that ends without producing content (a silent node, a tool-only turn) has its placeholder swept away; a replayed conversation never contains one either.

The feed: composites

The durable row model has the five kinds above. A generated client's snapshot.rows adds structure: when the agent's metadata describes a supported fan-out, repeatable node, or subgraph, the feed groups what belongs together into composite rows. The log stays flat; grouping is derived from the parity-gated folds. See also reopening during execution.

  • { type: "fan-out" } — one fan-out rendered as one row. nodeId names the source (so nodeNameOf works — the bubble reads "kickoff", not "the agent"), and tracks holds one row list per branch — each branch's own text, tools, and outputs, interleave-free. Render it as a card with a column per track, or a collapsed row that expands. A separate card requires recorded evidence of another firing, not just a repeated branch node. See Progress and topology for branch flags and the current branchStatus limitations.
  • { type: "subagent" } — one subgraph (subagent) rendered as one row. rows is the inner feed, recursively composited. The card can also carry descendant counts and the subgraph's status. A subgraph needs recorded content or run evidence to appear; an unreached subgraph has no feed card. A started black-boxed subgraph can have empty rows.
  • { type: "repeat" } — one activation of an ensemble or for_each_llm node rendered as one row. tracks groups its visible samples or items by their one-based iteration stamps. Ensemble samples arrive as complete rows; ordinary for_each content can stream. Structured ensemble decisions appear only for a public destination field; structured for_each produces no per-item repeat tracks. See the repeat reference for visibility and grouping details.

RepeatRow.done counts iterations represented by rows, not completed executions. total is the ensemble's configured sample count when available; it is absent for for_each, even after it finishes. An empty or abstaining sample leaves no track, so a finished ensemble can read 3 / 4. Use node/run status for completion, not done === total.

Rendering a generated feed

Pass a generated hook's snapshot.rows to a renderer that handles its composite kinds. This reusable renderer accepts FeedRow[], including all three kinds, and delegates the five durable kinds to the Feed renderer below:

import { isDurableRow, type FeedRow } from "@inlayai/react";

function GeneratedFeed({ rows }: { rows: FeedRow[] }) {
  return (
    <div>
      {rows.map((row) => {
        switch (row.type) {
          case "fan-out":
            return (
              <details key={row.key}>
                <summary>Branches</summary>
                {row.tracks.map((track, branch) => (
                  <GeneratedFeed key={branch} rows={track} />
                ))}
              </details>
            );
          case "subagent":
            return (
              <details key={row.key}>
                <summary>Subagent</summary>
                <GeneratedFeed rows={row.rows} />
              </details>
            );
          case "repeat":
            return (
              <details key={row.key}>
                <summary>
                  {row.mode === "parallel" ? "Samples" : "Items"}: {row.done}
                  {row.total === undefined ? "" : ` / ${row.total}`} with content
                </summary>
                {row.tracks.map((track) => {
                  const iteration = track.find(isDurableRow)?.iteration;
                  return (
                    <section key={iteration}>
                      <h4>Iteration {iteration}</h4>
                      <GeneratedFeed rows={track} />
                    </section>
                  );
                })}
              </details>
            );
          default:
            return <Feed key={row.id} rows={[row]} />;
        }
      })}
    </div>
  );
}

Fan-out tracks follow the pinned graph's branch order. Repeat tracks can be sparse and gain earlier-numbered samples as results arrive: use a track's durable iteration stamp for its label and key, not its array index. Each SDK-derived repeat track has at least one such row.

For an agent-specific component, use its generated row or snapshot type to handle only the kinds that graph supports. An agent without accepted fan-out regions, subgraphs, or repeatable nodes needs only the five durable cases. Codegen widens the union when the graph gains a supported composite shape; an exhaustively checked switch catches the missing case at regeneration. These types describe possible shapes, not a guarantee that every run produces a card.

Fan-out membership comes from the generated module's authoritative REGIONS, projected from the shared graph analyzer, not rediscovered from NODES/EDGES in JavaScript. Unsafe static shapes stay flat; a cycle confined downstream of the selected join does not by itself prevent a fan-out card. Regenerate older clients to get these descriptors: missing metadata does not trigger runtime graph analysis.

The honesty rules, stated once: composites carry a key (the view identity) and never an id, because they are not log rows. Missing structural or activation evidence leaves the affected rows flat. With a generated feed config, composition also requires the conversation's actual agent id and version to match the generated pin, with an explicit version greater than zero. A mismatch, an allowed foreign-agent reopen, draft version 0, or an unknown expected version keeps the full paced rows flat, including inner rows. The guard disables grouping, not content or pacing; see the reference contract.

A durable client-tool pause splits the turn into two adjacent rows with the tool call (and its result, when delivered) between them — both labeled with the node's name, so the interruption reads as one thought, paused and resumed.

Reopening during execution

An in-flight reopen reads persisted rows; it does not reattach to the original live stream. Each node's completion is flushed before its node.end frame is forwarded. After a successful write, a read whose window contains that row can restore its status, finish time, error, and usage while downstream work continues.

Completion does not predict routing. Each actual edge is recorded separately when it fires, and exitedVia and branch views derive from those edge facts. A sequential fan-out can fire further edges after an earlier child finishes; the node's completion row does not wait for or duplicate the exit set.

Other rows remain batched, and text in an open assistant turn may not yet be persisted. Writes remain best-effort: a storage failure can leave a fact visible live but absent on reopen. hasEarlier describes a windowed durable log, not whether the log is caught up with every live frame.

Pagination follows append order (seq), not row-id order. Use sequence cursors for paging, not row ids as a persistence high-water mark: an assistant turn keeps the id minted at its open even when it is banked later.

Rendering it

This renderer handles the five durable kinds. Use it directly with the flat rows from raw useInlayChat, or as the leaf renderer for GeneratedFeed above.

function Feed({ rows }: { rows: Row[] }) {
  return rows.map((row, i) => {
    switch (row.type) {
      case "user":
      case "assistant":
        return <p key={row.id}>{row.content}</p>;

      case "output":
        return (
          <figure key={row.id}>
            <figcaption>{row.name}</figcaption>
            <pre>{typeof row.value === "string" ? row.value : JSON.stringify(row.value)}</pre>
          </figure>
        );

      case "tool-call":
        return (
          <code key={row.id}>
            {row.toolName}({row.input})
          </code>
        );

      case "tool-result":
        return <code key={row.id}>→ {row.output}</code>;
    }
  });
}

Because the union is discriminated, row.content on a tool-call row does not compile — you get the fields that kind actually has, and no others.

Keys

Use row.id for durable rows and row.key for composites. A composite is a derived container, not a durable log entry, and deliberately has no id. Treat its key as opaque; do not parse it to recover node or activation data.

On supported servers, each durable row carries its server-minted id when it appears live and retains it on reopen. For a prefill placeholder, that identity already exists at node start; later turns and repeated items/samples get their own identities. A composite key is for view identity, not a persistence cursor.

Just the chat, please

Most UIs want the conversational turns and none of the machinery:

function Bubbles() {
  const turns = useInlayChat(conn).useTranscript();

  return turns.map((turn, i) => (
    <p key={turn.id ?? i} data-role={turn.type}>
      {turn.content}
    </p>
  ));
}

useTranscript() gives you top-level user and assistant rows only — no outputs, no tool calls, and nothing a subagent said while working. It is computed once when the snapshot commits, and subscribed at that slice, so a tool call landing does not re-render a bubble list that would have filtered it out.

The same derivation is available as a plain function, transcript(rows), for non-React code.

One node's rows

function NodePanel({ path }: { path: readonly string[] }) {
  const rows = useInlayChat(conn).useRowsAt(path);

  return <pre>{rows.map((r) => (r.type === "assistant" ? r.content : "")).join("")}</pre>;
}

useRowsAt(path) is the per-node slice, keyed by absolute path (subgraphPath + nodeId). It is reference-stable per path: a delta into one node leaves every other node's slice identical, so a panel showing node A does not re-render while node B streams.

This matters more than it sounds. A fan-out agent with ten branches streaming at once will re-render every panel on every token if you filter rows yourself in a selector. That is the mistake this API exists to prevent.

Timeline vs. snapshot

Two arrays, deliberately different:

  • snapshot.rows — the rendering feed. With a generated feed config and matching identity/version, inner work tucks into a subagent row, not dropped. When that guard disables composition, the full paced rows remain flat, including inner work. Without a feed config, including raw useInlayChat, it is the flat top-level transcript; inner rows stay out.
  • useTimeline() — everything, path-tagged, including inner-subgraph rows. This is what per-node and per-subagent views read.

If you are rendering a conversation, you want snapshot.rows. If you are building an inspector, you want useTimeline().

Next

Outputs appear in the feed and as a live keyed map, and the difference matters — Outputs and bindings.

On this page