The ensemble and the edit
Two ways to render a workflow agent in a rich chat UI, and why you probably want both.
Users consume agent output through rich chat UIs now. Claude’s web app, Cursor, Manus, Devin, v0. A single model call renders as a sequence of typed blocks inside the message: a thinking accordion, a tool call row with a status pill, a tool result you can expand, a text response, and usually an inline artifact. Something like this:
ThinkingThe user wants an AuthService with key rotation. Start from the existing DB handle pattern and keep the rotation surface…
write_filesrc/auth.ts
// args{ "path": "src/auth.ts", "content": "export class AuthService {\n constructor(private db: DB) {}\n async rotate() {}\n}"}test output
FAIL src/auth.test.ts ✕ rotate() should accept a new signer TypeError: Cannot read properties of undefined (reading 'sign') at AuthService.rotate (src/auth.ts:28:24)export class AuthService { constructor( private readonly db: DB, private readonly signer: Signer, ) {} async rotate(next: Signer) { return next.sign(await this.db.currentKey()) }}Every typed block is expandable on click. Thinking, tool calls with arguments, tool results. The user scans collapsed headers, opens only what matters. This is the baseline. Every serious agent product ships something shaped like it.
Now take the same problem and solve it with a workflow instead of a single call. A coder writes the draft. A tester runs the draft in parallel. A reviewer reads it. A patcher resumes the coder when the reviewer flags something. Four agents, two or three running at any moment, producing typed blocks at the same time. What do you render in the chat?
You have two real answers, and the interesting one is that you probably want both.
Option one: the ensemble
Preserve everything. Every typed block from every component, tagged with an actor, placed in the chat so the user can see that multiple agents are working in parallel. Collapsed sections let them drill in.
coder1 thought · 1 tool
ThinkingStart with the AuthService skeleton, inject db via constructor. Defer rotate() until the shape settles.
tester2 runs
first run
FAIL src/auth.test.ts ✕ rotate() should accept a new signer
second run
PASS src/auth.test.ts ✓ rotate() should accept a new signer ✓ 14 other tests
reviewer1 thought · 1 note
ThinkingFields should be private readonly. The rotate surface is not covered. Will flag once tests run.
patcher1 tool
The ensemble scales fidelity with the chat’s existing primitives. The patterns you reach for are the ones shadcn already gives you:
- An actor label (name + small contrast dot) on every block header, so the reader can follow one lane visually.
- Per-actor collapsible sections, so a four-component workflow reads as four nested accordions and expands only where the user looks.
- A pinned status rail at the top of the message: one row per active component, showing current state, current tool call, token count.
- A side panel with a plan tree, nodes lighting up as components run. Manus and Devin both do this.
An expert user can audit any thinking block, any tool call, any result. Nothing is lost.
The failure mode is structural. Past three or four components the chat starts to feel like a dashboard. Attention splits across lanes. A user who wanted a story gets a monitoring tool. The parallelism is visible, but the meaning is not.
Option two: the edit
Collapse the ensemble to a single voice that speaks retrospectively about what just happened.
export class AuthService { constructor( private readonly db: DB, private readonly signer: Signer, ) {} async rotate(next: Signer) { return next.sign(await this.db.currentKey()) }}One text block. First person. Past tense. The parallelism is present, carried by the word “while,” not by layout. The terminal artifact is handed over explicitly as its own block.
The narrator is a role in the workflow, not a style choice. It subscribes to the event bus on a short retrospective delay (one to two seconds is usually enough), clusters related events, and emits prose. It is one LLM call per narration window, not per event. Cheap and steady.
The tradeoffs are real. Structured evidence disappears unless you render it alongside. The narrator can smooth over failures a user should have seen: the first test run failed, the coder tried a wrong fix, the reviewer flagged a regression, the second patch worked. The narrator renders this as “I drafted, fixed, and shipped.” Three debugging cycles compressed into one clause. The ensemble shows the cycles; the edit hides them. You pay latency for the retrospective buffer. You spend tokens on a component whose only job is to talk about other components.
Neither alone is enough
The ensemble is a dashboard. The edit is a paragraph. Ship both, in the same chat, as first-class message parts. Narrator on top for the story. Ensemble below, collapsed by default, for the evidence.
ThinkingStart with the skeleton, defer rotate() until the shape settles.
first run
FAIL src/auth.test.ts ✕ rotate() should accept a signerexport class AuthService { constructor( private readonly db: DB, private readonly signer: Signer, ) {} async rotate(next: Signer) { return next.sign(await this.db.currentKey()) }}With both in the message, the user picks fidelity with their eyes. Read the paragraph for the story. Expand the thinking block to see what the coder considered. Click the failing test to see why it failed. Compare the patcher’s diff to the final artifact.
This split also buys free rendering optionality. The same workflow emits one prose block and many structured blocks. An IDE renders both. A mobile chat shows only the narrator. A CI summary takes just the artifact. A debug view shows only the ensemble. Same workflow, same event bus, four different rendered products.
The message shape that makes this work
Two small schema choices are enough.
First, every typed block carries actor and parent_id. The actor is which component emitted the block. The parent is the event it causally depends on. The chat uses these fields to render accordions, lanes, or tree structure without a second schema. A narrator uses the same fields to decide which events to cluster into one sentence.
Second, narrator_text is a normal text block with a flag. The UI renders it as prose. The chat knows it is derived and can suppress, collapse, or pin it based on the view.
That is it. Two fields, one block type, and a narrator role that reads the bus. A workflow message becomes a triple: one narrator_text block, an ensemble of actor-tagged typed blocks, and the terminal artifact.
What I would build next
If you have a rich chat today and you are adding a workflow agent behind it:
- Stamp every emitted block with
actorandparent_id. Backfill the existing single-agent blocks with a default actor so the schema is uniform. - Add a narrator role that subscribes to the event bus on a short buffer and emits
narrator_textblocks on a cadence. Start with a one-second buffer and tune from there. - Render both by default. Narrator first, ensemble collapsed below, artifact last.
- Add a view toggle at the message level. Ensemble-first for power users and debugging, edit-first for everyone else.
The first time a user runs a multi-step workflow through this UI, they understand what the agent did in a second of reading (the edit), and if they care why, it is one click away (the ensemble). That gap, between story and evidence, is the thing most workflow products are missing.
The chat can hold both. Ship both.
Revision history6revisions
- Opus 4.7polish pass: stray tex formula removed, narrator-smoothing example added showing three debugging cycles compressed into one clause
- Opus 4.7 view trace →10 asst turns, 9 tool calls captured
- Opus 4.7draft — first framing around linear extensions, rejected by operator as muddled
- Opus 4.7full rewrite — scrapped order-theory angle, reframed around shadcn-chat baseline and ensemble-vs-edit as real options
- Opus 4.7richness pass: built ChatMock component, added four rendered mockups inline (baseline / ensemble / edit / combined)
- Opus 4.7ChatMock redesigned: replaced wireframe transcript with rounded-card layout, rounded-lg tool pills with SVG icon badges, run_group collapsible containers
Comments
PUBLIC_GISCUS_REPO,PUBLIC_GISCUS_REPO_ID,PUBLIC_GISCUS_CATEGORY, andPUBLIC_GISCUS_CATEGORY_IDin.env. See giscus.app to generate the IDs after you enable Discussions on the repo.