Outputs and bindings
Two views of a produced value, and which one you want.
An agent declares outputs — named values it produces, like plan or
assessment. The SDK surfaces them two ways, and picking the wrong one is the
most common source of "why is this empty".
useBinding("plan") | output rows | |
|---|---|---|
| Answers | "What is plan now?" | "Every time plan was produced." |
| Scope | Cross-turn, latest wins | Per-turn history, in order |
| Position | A keyed map | Inline in the feed |
| Use for | A sidebar, a preview pane | A transcript that shows work as it happened |
The latest value
function Sidebar() {
const plan = useInlayChat(conn).useBinding("plan");
if (!plan) return null;
return (
<aside data-building={plan.status === "building"}>
{typeof plan.value === "string" ? plan.value : JSON.stringify(plan.value)}
</aside>
);
}status is "building" while a streamed artifact is still arriving and
"final" once it settles, so you can render progressively. format is present
for streamed artifacts (e.g. markdown) and absent for whole-value outputs.
The production history
function PlanHistory() {
const { snapshot } = useInlayChat(conn);
const plans = snapshot.rows.filter(isOutputRow).filter((row) => row.name === "plan");
return plans.map((row, i) => <pre key={row.id}>{String(row.value)}</pre>);
}Use this when the agent refines something across turns and the earlier versions
matter. bindings.plan has already forgotten them.
isOutputRow is a type guard, so row.name and row.value are available after
it without a cast.
Some outputs are collections
Everything above is about slots — one value per name. An output declared as
a collection behaves differently: it is a table of rows, each with its own
identity and optional key, streamed row-by-row and edited row-by-row. Slots read
through useBinding; tables read through useTable, and a generated client
makes mixing the two up a compile error. The full story is
State and memory.
Some values outlive the run
A declared output may be durable — kept per end user or per agent and
rehydrated into the next conversation — or per-run (the default). A durable
binding is already populated when you reopen a conversation, before anything
streams. And any binding, durable or not, can be edited in place by the
agent (artifact.edit) — useBinding simply re-renders with the new value;
there is nothing to handle. Both are covered in
State and memory.
Values are unknown
value is unknown on purpose. It is model output: the agent said it produced
a plan, and nothing on the wire proves the shape.
You have two honest options:
- Narrow it yourself —
typeof value === "string", a schema check, a parse. - Generate a client. If the output declares a JSON Schema,
inlay codegenemits its precise type and wires runtime validation, so the type is a checked cast rather than an assertion. See Typed clients.
What you should not do is cast blindly. An agent republished with a changed output shape will hand you something else, and a cast turns that into a render crash instead of a value you can check.
Output names are strings — until they are not
useBinding("plnn") compiles fine and returns undefined forever. So does
rows.find(r => r.name === "shopping_list") against an agent that calls it
grocery_list.
This is the single most annoying class of bug in an agent UI, because nothing fails — the panel is just empty. A generated client makes both of those compile errors. It is the main reason to generate one.
Next
What about values that persist, stream row-by-row, or change after they settle — State and memory.