In this article

A transcript is not a control surface

If you are putting an agent into a product people already use, the first decision is not which model or which framework. It is where the agent’s writes appear. This piece is about that decision, and about the two families of protocol that have grown up around it.

Nearly every agent feature ships the same way at first: a chat panel docked next to the product, and the product itself unchanged. The agent lives in the panel, the work stays in the product, and what connects them is a person who reads a claim in the panel and checks it in the product, by hand.

This layout made sense when software only answered a question, because a transcript is exactly the right interface for that exchange. An agent that acts changes things: a filter, a record, an order. A transcript can only describe those changes; it cannot make them visible.

The transcript is useful. It is not the product. The product is the thing that changed, and whether a person can stop it.

That is why the chat box, inside an agentic product, degrades into a log of what was said, enough for an audit and nothing a person can steer with. The person finds out about each change only by reading about it, too late to stop it, and quickly stops trusting the panel.

Trust here is an interface property, not a model property.A better model behind the same panel changes none of it: the person still cannot see, stop or take back the change. The write has to appear where the person already looks, while it is still reversible, and there are now protocols for exactly that. They split into two families.

The write must land in the product

Make it concrete. An ordering desk shows a table of products, each with a reorder point: the stock level at which the system orders more. The agent, asked to prepare a promotion week, wants to raise the reorder point of one product, SKU 4711, from 800 to 1,240 units. There are two places this can happen. In the sidebar: “I have updated the reorder point for SKU 4711 to 1,240.” Or on the row itself: the number changes, the row is marked as proposed, and accept and undo sit next to it.

The second version supplies what the first only asserts: the row is the one the person already watches, the old value stays legible beside the proposed one, and the change can be stopped on the object, before it counts.

Run the same scripted agent through both versions:

Live on this page

The transcript: a log

Nothing yet. Start the run.

The product: the ordering desk

SKUReorder point
4711800 units

A scripted run of the same agent, shown twice: as a transcript, and as events landing on the product. The event chips are the AG-UI vocabulary from this section.

The second run is what vendors ship under the name generative UI. The requirement behind it is old: put something in front of the person that they can refuse. The row existed already, so this screen only had to be updated. But ask the same agent to compare three suppliers side by side and no screen for that exists; the comparison has to be assembled at runtime, from parts you still control. The two protocol families split along that line: the first streams into screens you already have, the second builds the screens you do not, and vendors attach the generative-UI label to both.

The first family streams changes into screens you already have. The second requests a screen you never built, and your catalogue renders it.

AG-UI: the product hears the agent

Take the two families in turn, starting with the one you are most likely to need first. The inbound problem comes before everything else: the product has to hear what the agent is doing, in time to show it. AG-UI’s answer is an ordered stream of typed events between agent and frontend: lifecycle events when a run starts and finishes, message events for the talk, tool-call events when the agent invokes something, and state events, StateSnapshot and StateDelta, when the shared picture changes.

The events that matter for the ordering desk are not the message tokens. ToolCallStart is when the row gets marked as in progress. StateDelta is what puts the proposed number on it. The transcript, if you keep one, is TextMessageContent, and nothing on the desk depends on it.

AG-UI itself is less new than the name suggests: an ordered stream of typed events is server-sent events with a schema, and your product may stream already. What the protocol buys is the two parts you would build wrong or skip: the run that suspends and waits for a person without blocking the stream, and a frontend wiring that survives swapping the agent backend. A team with one backend and no appetite for churn can hand-roll the few event kinds it needs and lose little.

CopilotKit is one implementation of this layer; its team started the AG-UI protocol. This is the shape that compiles against the package you get from npm today:

useFrontendTool({
  name: "propose_reorder_point",
  description: "Show a proposed reorder point on the SKU row.",
  parameters: [
    { name: "sku", type: "string", required: true },
    { name: "units", type: "number", required: true },
  ],
  handler: async ({ sku, units }) => {
    markProposed(sku, units);
    return "Proposal is visible on the row.";
  },
});

useHumanInTheLoop({
  name: "confirm_reorder_point",
  description: "Wait until the person accepts or rejects the proposal.",
  parameters: [
    { name: "sku", type: "string", required: true },
    { name: "units", type: "number", required: true },
  ],
  render: ({ args, respond, status }) => (
    <ProposalOnRow
      sku={args.sku ?? ""}
      units={args.units ?? 0}
      pending={status === "executing"}
      onAccept={() => respond?.("accepted")}
      onReject={() => respond?.("rejected")}
    />
  ),
});

useFrontendTool runs in the page and may paint the proposal, but its handler returns straight into the agent’s own run: whatever the handler answers arrives as tool output, words the agent itself reports. A yes from there would be the agent confirming itself. useHumanInTheLoop is different: it suspends the run until respond fires, and its render prop is the chance to put that confirmation on the row rather than in the transcript.

One warning, earned while compiling these snippets against the real package: CopilotKit’s newest documentation shows a different API, zod schemas and a hook called useAgent. Both APIs turn out to be real: the same package version ships the new one behind a /v2 subpath import, while the plain import keeps the hooks above, and nothing on either side tells you which one a tutorial assumes. Write the import path down next to the code, verify that it compiles, and date the note. This layer is moving fast enough that the difference between two import paths is a failed build.

Tell the agent what is on the screen

The hooks above cover the agent’s writes. The reads need a wire too. An agent that cannot see today’s reorder points will invent them, and an invented number looks exactly like a real one. Teams debug that as a model problem, until someone discovers that nobody told the agent what is on the screen. The wire is one hook:

const { state } = useCoAgent({
  name: "ordering_desk",
  initialState: { reorderPoints: { "4711": 800 } },
});

useCoAgent shares one object between the page and the agent. The agent reads today’s numbers from it, and the agent’s StateDelta events land in it, which is what put the proposed value on the row earlier. One object, read by both sides, carries what is true right now.

Two writers share that object, and the collision arrives in week one: the person edits the reorder point while the agent’s proposal is in flight. The person wins. Their edit throws out the pending proposal, the row settles on their number, and the agent finds out through the same object it reads anyway. Any other resolution has the agent overriding the person on their own screen.

Undo is a state machine, not a button

Between the proposal and the committed record, accept and undo look like two buttons. Underneath them is a small state machine, and every write an agent proposes walks it. Four states cover the row’s whole life, and the real decisions live in two transitions:

type RowState =
  | { kind: "settled"; units: number }
  | { kind: "proposed"; units: number; proposedUnits: number }
  | { kind: "committing"; units: number; proposedUnits: number }
  | { kind: "committed"; units: number; previousUnits: number };

// The commit can refuse:
case "commit-failed":
  return state.kind === "committing"
    ? { kind: "proposed", units: state.units,
        proposedUnits: state.proposedUnits }
    : state;

// And undo after commit is not time travel. It is a new
// write with the old value, walking through the same gate:
case "person-undid":
  return state.kind === "committed"
    ? { kind: "committing", units: state.units,
        proposedUnits: state.previousUnits }
    : state;
Every transition has a sender. A refused commit falls back to the visible proposal; undo enters as a new write, through the same gate.

Every transition has a sender. StateDelta from the agent marks the row as proposed. The respond() call inside useHumanInTheLoop is the person accepting or rejecting. And commit-succeeded comes only from your backend, never from the agent, because the agent does not get to say yes to itself. Sooner or later the backend refuses a commit; then the row falls back to the visible proposal, instead of lying about what the database holds.

Live on this page
SKUReorder pointState
4711800 unitssettled

The machine from the code above, running. Let the agent propose, accept, then undo, and watch the undo walk through the same committing state as the original write. Tick the failure box to see the commit refuse and the row fall back to the proposal.

How much of this machine a write needs comes down to one question: can it be taken back? The proposal changes nothing real until accepted, so showing it costs nothing. The reorder point lands in the database, but it has an inverse: its undo is simply a new write with the old value, walking the machine like any write. The machine matters here because someone else may have changed the number in between, and an undo that silently overwrites them is a new mistake, not a rescue. The order that has already gone to the supplier has no inverse at all. For that write, the only honest interface is a confirmation gate before the commit, not an apology after it. Undo is not a button you add at the end. It is a property you either designed into the write, or do not have.

The machine’s states also have to reach screen readers. When a proposal appears on a row, a screen-reader user hears nothing unless the row announces it. An aria-live region on the changing cell, polite rather than assertive, tells the same story the highlight tells sighted eyes, without stealing focus; the demo above does this. Visibility does not stop at pixels.

The machine is also testable, because the agent in the demo is scripted: a browser test can drive it deterministically, propose, accept, undo, then assert on the row. The pattern generalises to any agent surface: script the agent, then assert on the product, not on the transcript.

A2UI and MCP Apps: the agent proposes interface

The second family starts where the first stops: the agent needs an interface the product does not have. A comparison of three suppliers, a follow-up question with four options, a small form. Without a standard, this case already has an answer, and it is the worst one: the model prints a markdown table into the chat panel and asks the person to reply with a number. That is model-chosen interface too, just ungoverned, styled by nobody, confirmed by nothing. So the question is not whether the model may put something in front of the person; it does that in every chat product today. The question is whether that choice runs through your design system or around it.

Two standards close that gap, and both refuse the obvious shortcut of letting the model write code into the page. A2UI, an open standard in early public preview, has the agent send plain JSON naming what it needs: a card, a choice, a form. You designed those components. The agent only picks and fills, like a support rep picking the right form for a caller, and nobody calls that designing forms. The JSON is worth seeing once, because its shape is the security model:

{
  "version": "v0.9.1",
  "updateComponents": {
    "surfaceId": "proposal",
    "components": [
      { "id": "root", "component": "Card", "child": "col" },
      { "id": "col", "component": "Column",
        "children": ["summary", "accept", "reject"] },
      { "id": "summary", "component": "Text",
        "text": { "path": "/proposal/summary" } },
      { "id": "accept", "component": "Button", "text": "Accept",
        "action": { "event": { "name": "accept_proposal" } } },
      { "id": "reject", "component": "Button", "text": "Reject",
        "action": { "event": { "name": "reject_proposal" } } }
    ]
  }
}

Nothing in it is code. Card, Column, Text and Button are names the agent may use because the client’s catalogue defines them. The summary arrives through a data binding rather than as markup, and the buttons carry no handlers, only named events the product decides how to answer. A component outside the catalogue simply does not render.

MCP Apps, the first official extension to the Model Context Protocol, reaches the same refusal another way. The tool’s interface is written ahead of time and ships with the server as a ui:// resource; the tool only points at it:

const resourceUri = "ui://ordering-desk/proposal.html";

registerAppTool(server, "propose_reorder_point", {
  title: "Propose reorder point",
  description: "Show a proposed reorder point for one SKU.",
  inputSchema: { sku: z.string(), units: z.number() },
  _meta: { ui: { resourceUri } },
}, async ({ sku, units }) => ({
  content: [{ type: "text", text: `Proposed ${units} for ${sku}.` }],
}));

The interface itself renders in a sandboxed iframe and talks to the host over the same JSON-RPC the protocol already uses; the host decides whether and where the panel appears. Different mechanics, same refusal: the agent proposes, the client renders, and nothing the model writes executes with the page’s authority.

Where the card renders is the part the vendor demos get wrong. The pitch is a floating panel over your product, and the vendor cannot do better: their renderer has never seen your screens. But a confirmation card in an overlay is the sidebar’s mistake again, one level up. It looks like a confirmation and has none of the properties: the old value is not beside it, no row is marked, and the Accept button belongs to nobody’s design system.

So the card needs a surface somebody owns, and only two exist. Your product, rendering from your catalogue, is one. A host like Claude or an IDE, rendering in its own surface under its own rules, is the other, and that is the case MCP Apps is built for. A layer that belongs to neither follows nobody’s rules.

The design work concentrates in the catalogue. Deciding which components an agent may request, and which confirmations they carry, forces the product to state what may happen on its screens. It is the same exercise as deciding which actions a page declares as callable tools for agents.

Keep the catalogue small: pieces for interaction, never pieces for layout. Asking, choosing, confirming and comparing qualify; tabs and columns do not. A small catalogue is also what makes the output stable. The same situation then produces the same card, with different data in it, exactly as a designed screen would. If one question can come back looking different every time, the catalogue has handed the model the layout freedom you meant to withhold.

One rule is missing from both standards, and it is the one I would add first: a card that keeps coming back is not interface, it is a backlog item. Log every composition the agent requests. The log is a list of screens your product turned out to need, and it is precise enough to hand to a coding agent as the specification. Recurring entries graduate into designed screens, behind an ordinary review. The composition serves this conversation; the graduation serves the next hundred. What must never happen is the quiet default: the same card re-derived forever, because nobody asked whether it deserved to persist.

Choosing between them

You do not have to pick a winner here. The two families answer different questions, and most products that go far enough need both. A write that lands on interface you already own, a row, a filter, a form, takes the first family: stream the events into your components, confirm on the object. An agent that needs interface you never designed takes the second: it proposes from the catalogue, and the client renders. Inside the second family the split is ownership: A2UI when you own the client and its catalogue, MCP Apps when you ship a tool into someone else’s host.

Price it before choosing. Below some stakes neither family is worth buying: an internal, read-mostly tool with five users can live with the log. Above them, own the pattern and rent the protocol. Confirmation on the object and the undo machine are plain code in your own components; the protocols buy interop, nothing else. A2UI at v0.9 makes the point sharpest: build the catalogue and the named events as yours, and treat the wire format as a serialization you can swap when the spec moves.

Vercel’s AI SDK arrived at the same two answers from its own direction. Its useChat hook hands each tool call to your own components as a typed message part: family one under another name. Its json-render library has the model emit JSON against a component catalogue defined in zod, and the client renders it: family two, authority in the same place. The approach Vercel tried first and set aside, streaming model-chosen interface straight out of the server, is the one both families refuse. These projects share people and protocols, so the agreement is not independent discovery. But every one of them, on its own schedule, moved rendering authority to the client and kept it there.

The trap is the default: the sidebar alone. It is the cheapest version to ship and the hardest to trust, and a team that stops there will read the result as proof that people do not want agents in the product. The finding is narrower: people refuse software that changes their work where they cannot see it.

If you already have an agent behind a sidebar, you can test all of this on one write this week. Take the write your users complain about most, and move it out of the transcript and onto the thing it changes: the row, the record, the order. Give it a proposed state, an accept and an undo. Then watch what people do with it, because the interesting result is not that they accept faster. It is that they start reading the proposals, which they were never doing in the panel. The transcript keeps its place as the log it always was, and the part of the work that decides whether anyone trusts the agent moves back onto the product.

Verified · August 2026
  • AG-UI: event-stream protocol; event families include lifecycle, text message, tool call (ToolCallStart/Args/End/Result) and state (StateSnapshot, StateDelta, MessagesSnapshot); newer additions document activity and reasoning events, with interrupt events in draft. Source: docs.ag-ui.com.
  • CopilotKit: verified by compiling the snippet above against @copilotkit/react-core 1.68.1 (lab: labs/the-chat-box-is-a-log). The undo demo is driven by a Playwright test in this repository: propose, accept, undo, assert on the row. Exported hooks include useFrontendTool (successor to useCopilotAction, both exported), useHumanInTheLoop, useRenderToolCall and useCoAgent. The documented “v2” SDK renames useCoAgent to useAgent and moves parameters to zod schemas; the same 1.68.1 package ships that shape behind the @copilotkit/react-core/v2 subpath, while the root import keeps the shape above.
  • A2UI: early public preview; v0.9.1 stable, v1.0 spec release candidate; declarative JSON against a client component catalogue; compatible with A2A and AG-UI as transports. Messages are createSurface and updateComponents; components form a flat adjacency list with data bindings (spec).
  • Vercel AI SDK: useChat renders tool calls as typed parts with input-available / output-available / output-error states; the earlier RSC streamUI path is experimental and no longer the recommended default; json-render (Apache-2.0) renders model-emitted JSON against a zod-defined catalogue. Source: ai-sdk.dev.
  • MCP Apps: first official MCP extension (spec 2026-01-26); ui:// resources, text/html;profile=mcp-app, sandboxed iframes, JSON-RPC to the host. Tools link their interface via _meta.ui.resourceUri; registerAppTool and registerAppResource ship in @modelcontextprotocol/ext-apps (build tutorial); the snippet above compiles in the lab against ext-apps 1.7.5. These values move; check before you rely on them.