Six events replaced thirty
At one point our chat runtime spoke two dialects: a legacy SSE protocol with 24 event types and a WebSocket protocol that had grown past 30. Nobody planned that. Each type was added for one feature by one engineer solving one rendering problem, and every new frontend surface then had to reimplement handlers for all of them, badly. The rewrite collapsed both protocols into a discriminated union of exactly six event types: token, tool, artifact, status, done, error. Every event ships inside a wire envelope carrying a request ID for correlation, a monotonic sequence number for ordering and gap detection, and a timestamp.
Six is not a magic number. It is the number you get when you ask what a rendering surface actually needs to know: text is streaming (token), work is happening (tool), a durable thing was produced (artifact), the phase changed (status), we are finished (done), or we are not (error). Everything else in the old protocols was ceremony. The maintenance math alone justified the rewrite: every renderer we add now implements six handlers, and we add renderers a lot more often than we add protocols.
The sprawl had a second-order cost that took longer to see. With 30-plus types, no two surfaces implemented the same subset, so the same agent turn rendered differently in the main chat, the dashboard chat, and the admin console, and every difference generated a bug report. The fix was organizational as much as technical. There is now a written rule in the repo instructions that the primary chat surface is where every rendering change lands first, and the six-event protocol is what makes obeying that rule cheap: a secondary surface that handles six events correctly is done, not almost done.
The union is closed on purpose, and staying closed takes active defense. When a later agent pipeline needed six new lifecycle markers for its planner and polisher stages, the temptation was to add six event types, which is exactly how the old protocol got to 30. Instead they shipped as kinds of the existing status event, whose payload is discriminated by a kind field: phase, thinking summary, suggestions, title, and so on. The frontend's exhaustiveness checks over the six-type union did not change. New capability, zero protocol growth. Any protocol without a rule like this has a default trajectory, and the default is sprawl.
One event type earns the article. The token stream is disposable by design; it even carries a channel flag separating visible response text from the model's reasoning tokens, and the frontend does not need to reassemble any of it, because the done event carries the authoritative full text for persistence. The artifact event is the opposite of disposable. It is the reason the other five exist.
The shape of one turn
Here is a simplified trace of a single question about revenue, as the frontend sees it. Tool events share a call ID across their lifecycle, so one invocation is one span: a start, optional progress, and exactly one complete or error.
seq 00 status kind=phase data: planning
seq 01 tool call=t1 name=query_semantic phase=start args: domain=revenue
seq 02 tool call=t1 phase=progress label: "1,240 rows returned" row_count=1240
seq 03 tool call=t1 phase=complete duration_ms=412 artifact_ids: [a-7c19]
seq 04 artifact artifact_type=chart artifact_id=a-7c19 data: {...}
seq 05 token channel=main "Revenue held steady week over week..."
...
seq 41 done message_id=m-2210 full_response=<authoritative text> trace_id=<observability id>
every line wrapped in an envelope: request_id, monotonic seq, ISO timestampTwo design choices in that trace matter more than they look. First, the tool-complete event carries a typed result with a one-line summary, a row count, and the IDs of any artifacts it produced, but never the raw data blob. Large payloads live only in artifact events, which means the tool stream alone is enough for any headless consumer to know what happened and where the data lives. Second, the error path is paranoid on purpose. The error event schema hard-codes a generic user-facing message, and the class comment states why in words I will quote exactly: raw payloads and stack text have leaked into chat. That happened. Our stack traces were readable by whoever asked the right malformed question, and the fix is structural now, not a code-review reminder. Full detail goes to server-side logs; the wire gets the generic line.
Eight artifact types, three deletions
The artifact_type field is the discriminator the renderer switches on. Eight types are active, and each one is active because a tool produces it and a frontend handler renders it.
| Type | What it is | Afterlife |
|---|---|---|
| chart | Inline visualization (7 chart forms) | Session workspace, promotable to a canvas widget |
| table | Structured tabular data | Session workspace, promotable |
| insight | Verifiable finding with 4-tier disclosure | Pinned to the insights system with a baseline snapshot |
| analysis | Generic analysis results | Session workspace, promotable |
| quick_action | Operational action proposal | Tracked action item with status |
| insight_edit | Proposed edit to an existing insight | Applied to the saved insight on approval |
| review_artifact | Progress review | Session record |
| report | Compound document with sections | Investigation reports |
The more interesting list is the one that got deleted. Three payload types (markdown, decision, action) were removed from the union because no tool produced them and no frontend handler existed. They were aspirational types, schema for features we might build, and aspirational schema is debt that lies about your capabilities. The enum's own docstring says to re-add them when tools exist. Until a tool produces it and a renderer renders it, a type is fiction.
Persistence, or it did not happen
Streaming an artifact solves rendering. Persistence is a separate, harder promise, and it lives in a session_artifacts table. Every artifact generated during a chat is written there with its session ID, its type, its full JSONB payload, and a lifecycle status: active while it lives in the session workspace, promoted once the user adds it to a canvas, archived when hidden. The agent can also upsert its own artifacts mid-session through an external artifact ID that is unique per session, enforced by a partial unique index, so a refined chart replaces its earlier draft instead of stacking a duplicate.
The two-stage design is the part I would defend in an architecture review. The mini canvas is the session workspace; artifacts persist there automatically, so closing a tab loses nothing and resuming a session restores every artifact intact. The persistent canvas is a separate concept. When a user clicks add-to-canvas, the artifact is promoted into a saved insight, the artifact row records the saved insight's ID and a promotion timestamp, and the canvas widget references the saved insight rather than copying the payload. One promotion path, no duplicated data, and the artifact row keeps a list of every canvas it landed on. A pinned insight also gets baseline metric snapshots written at save time, which is what lets next month's question about the same metric start from a lookup instead of a full re-run.
Placement is typed too. A canvas is a 12-column grid, and an artifact landing on it takes a grid position with defaults of 6 columns wide by 4 rows tall, override-able per request. Small detail, but it means promotion is an API call with a validated schema, which means the agent can do it, the user can do it, and a bulk migration can do it, all through one code path. Every convenience feature we later built on artifacts (pin from chat, re-run from canvas, edit proposals against saved insights) exists because the promotion path was a real API and not a UI-only gesture.
Tools with manners
Two tools define the artifact workflow, and they have deliberately different manners. The chart tool renders any of seven chart forms inline (bar, vertical bar, grouped bar, pie, donut, line, area) with typed units for currency, counts, and percentages. Its instructions tell the agent that after showing a chart it should always ask whether the user wants it on their dashboard. Show first, offer second.
The dashboard tool is the one with power, so it is the one with rules. It can add, remove, move, or update widgets on a 12-column grid across ten widget types, and its instructions are written in the imperative: only act on an explicit user request, and never modify the dashboard automatically based on conversation context. The display tool volunteers. The mutation tool waits to be asked. That split is my general position on agent authority: read-and-render can be eager; anything that changes persistent state needs a human sentence requesting it.
The same courtesy extends to iteration. When the user asks for the chart as a line instead of a donut, the agent upserts through its own artifact ID rather than emitting a new artifact, so the workspace holds the current version instead of five abandoned drafts. And artifacts that propose work rather than display it, the quick actions, graduate into tracked action items that record their status and which surface they came from, so a commitment made in chat is queryable weeks later instead of buried in a transcript.
On the frontend, the widget registry enforces a different kind of honesty. Every widget carries a data-status field: real, calculated, or stub. We added it after early widgets displayed derived and placeholder values that were visually identical to live data. Nobody was lying; the pixels were. Now the provenance of a widget's data is metadata the UI can disclose, calculated widgets must document their formula, and stub widgets declare exactly which data would make them real.
Same tokens, different asset
The board framing is simple. A chat answer and a promoted artifact cost the same tokens to produce. The chat answer is write-once, read-once: it scrolls up and dies, and the next person with the same question pays the full agent run again. The promoted artifact is an asset. It sits on a canvas that gets reopened every morning, it carries its provenance and baseline snapshot, it can be re-verified against live data, and the follow-up question about it costs a database lookup instead of an agent invocation. Same spend, different balance sheet. Over a year of daily operator use, the ratio of answers-consulted to answers-generated is the whole economics of the product.
This also reframes what the chat transcript is for. Ours persists, obviously, but I think of it the way I think of a shell history: useful for context, occasionally consulted, never the deliverable. The deliverables are the rows in the artifact table and the insights they became. When I review usage, the number I watch is promotions per session, because a session that produced zero promotable artifacts was either a casual question (fine) or a failure to produce anything worth keeping (a product problem). Token volume, the metric most teams watch, measures activity. Promotions measure whether the activity left anything behind.
So the stance, stated plainly: if your agent's output cannot outlive the conversation, you built a demo. The 2026 default is still the opposite; most agent products treat the transcript as the product and persistence as an export button. I think that default is backwards, and I think the teams that agree with me are quietly winning enterprise deals from the teams that do not, because a buyer evaluating an agent platform eventually asks what their people will have accumulated after a year of use, and "a very long chat history" is a bad answer. Chat is an input device. The artifact is the unit of work. Judge an agent platform by what exists after the tab closes.