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:
type | Carries | Is | Availability |
|---|---|---|---|
user | content | Something the person said. | Raw and generated |
assistant | content, reasoning, complete | Something the agent said. | Raw and generated |
output | name, value, status, format, complete | A declared output it produced. | Raw and generated |
tool-call | toolCallId, toolName, input, requiresClientResponse | A tool it decided to call. | Raw and generated |
tool-result | toolCallId, output | What that call returned. | Raw and generated |
fan-out | key, tracks, join | A supported fan-out's branch feeds. | Generated only |
subagent | key, rows | A subgraph's inner feed. | Generated only |
repeat | key, 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.nodeIdnames the source (sonodeNameOfworks — the bubble reads "kickoff", not "the agent"), andtracksholds 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 currentbranchStatuslimitations.{ type: "subagent" }— one subgraph (subagent) rendered as one row.rowsis 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 emptyrows.{ type: "repeat" }— one activation of anensembleorfor_each_llmnode rendered as one row.tracksgroups its visible samples or items by their one-basediterationstamps. Ensemble samples arrive as complete rows; ordinaryfor_eachcontent can stream. Structured ensemble decisions appear only for a public destination field; structuredfor_eachproduces 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 asubagentrow, not dropped. When that guard disables composition, the full paced rows remain flat, including inner work. Without a feed config, including rawuseInlayChat, 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.