Dayward AI
Week 1 · D1About 4 hours

What Makes Agent Frontends Hard: an Event Protocol and Your First Stream in the Browser

Start with the three things that separate an agent interface from an ordinary chat interface, meet AG-UI as the open event protocol between backend and frontend, then read your first stream in the browser with plain fetch and ReadableStream, and understand why EventSource fails at step one.

Today's goals 0/3

Sign in to tick these off and save your progress.

Today's Goals

  1. Name the three things that make agent interfaces hard, with a concrete failure for each that costs you the user's trust
  2. Read the AG-UI event groups and explain what the run, message, and tool layers each solve
  3. Read an event stream with plain fetch and ReadableStream, and explain why EventSource does not work here

No interface work today. First the foundation: a protocol, and a decoder that turns bytes into events. When you finish, scroll back up and tick the three goals off.

Plain-Language Walkthrough

The simultaneous interpreter's booth: why speaking while listening is the hard part

Have you sat in a conference with simultaneous interpretation? The speaker talks, and from a soundproof booth the interpreter renders it almost at the same moment. What reaches your headset is a sentence taking shape in real time.

The difficulty is not vocabulary. It is that the interpreter has to start speaking while the information is still incomplete. The speaker says "this proposal, we have decided to", and the interpreter is already committed, without yet knowing whether the ending is "adopt" or "abandon". Hence the particular awkwardness of live interpretation: realizing mid-sentence that you went the wrong way and having to double back, listeners left holding half a clause, a sudden piece of jargon that stalls everything.

An agent interface is that booth. The model emits one word at a time, and your interface has to display a sentence before it exists in full. This is nothing like traditional frontend work, where you get a complete API response and render it once, deterministically. An agent frontend gets a stream with no promised ending. It might finish normally, it might error out halfway, it might stop to ask you a question, or it might still be running three minutes later.

So the position this course takes is: consuming a stream is easy; keeping the interface from falling apart once the stream arrives is the hard part. The next seven days are all about the second half of that sentence.

Three specific difficulties: uncertainty, long tasks, trust

Break "hard" apart and you get three concrete problems. Each has a signature failure, and each of those failures costs you the user's trust directly.

Uncertainty. You do not know how long the answer will be, whether it will call a tool, or whether it will fail. A traditional frontend can draw an accurate progress bar; you cannot, because you do not even know the total. The failure looks like this: a spinner turns for forty seconds, the user cannot tell whether the agent is thinking, calling a tool, or dead, and they reload the page and lose everything.

Long tasks. An agent running for several minutes is normal. A traditional frontend's request timeout sits around thirty seconds, and here the concept barely applies. The failure: the user switches to another tab to get on with work, comes back to find the stream died, and the interface shows half a sentence with no explanation and no way to recover.

Trust. Models are wrong and models invent things. If the interface presents nothing but a confidently written paragraph, the user has no way to judge it. The failure: the agent claims it deleted the files, and the user cannot see what it actually called, with what arguments, or what came back. All they can do is believe it or not.

Why a protocol: what AG-UI covers and what MCP covers

If the frontend is going to show what the agent is thinking, which tool it is calling, and how far along it is, the backend has to send all of that in a shape both sides agreed on beforehand. That agreement is a protocol.

Three protocols have appeared in the agent ecosystem over the last couple of years, each covering a different link:

ProtocolConnectsSolves
MCPAgent and toolsAny agent can use any tool without bespoke glue code
A2AAgent and agentAgents built by different teams can delegate to each other
AG-UIAgent and user interfaceAny frontend can display any agent's execution

Only the third one concerns us. AG-UI, the Agent-User Interaction Protocol, is an open protocol under the MIT license. What it does is unglamorous: it specifies the shape of every event the backend sends the frontend.

A decision worth explaining here. This course uses no agent frontend SDK at all; we write the client ourselves. But we do not invent the protocol — we align with AG-UI. The reasoning: invent a teaching protocol and all you learn is the one the instructor made up, whereas aligning with an open standard means the decoder you write and your grasp of the event layering transfer to any AG-UI backend. What you hand-write is a client for a real standard, not a reinvented wheel.

The event groups: six of them, one layer each

AG-UI groups events by responsibility. This course uses eighteen of them; today we meet three groups.

The run group marks the boundaries of one execution. RUN_STARTED opens with a threadId and a runId, RUN_FINISHED closes, and RUN_ERROR covers failure. This layer is what tells the interface whether anything is currently running, which in turn drives the stop button and the disabled input.

The text message group covers the generation of one reply, in three parts:

  • TEXT_MESSAGE_START carries messageId and role, announcing "a message is about to begin"
  • TEXT_MESSAGE_CONTENT carries messageId and delta, one small piece of text each
  • TEXT_MESSAGE_END carries messageId, announcing the message is complete

Why three events instead of one complete message? Because streaming means displaying before the sentence exists. START lets the interface build the bubble first, CONTENT fills it in, and END tells the interface it can wrap up: apply syntax highlighting, begin screen reader announcement, reveal the copy button. Explicit start and end are what let the frontend distinguish "still talking" from "done" — a distinction that matters for rendering optimization on day two and for accessible announcement on day seven.

The tool group covers the lifecycle of a tool call (TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END / TOOL_CALL_RESULT), which is day three's subject. The reasoning, state, and subagent groups belong to days four and six.

A network packet can cut an SSE line in half1/5

What the server wants to send (the full message)

data: {"choices":[{"delta":{"content":"Hi"}}]}
data: {"choices":[{"delta":{"content":" there"}}]}
data: [DONE]

The network packets that actually arrive

(nothing received yet)

The half-line left in the buffer

(empty)

Complete events parsed so far

(none yet)
The server sends text in SSE format: one event per line, starting with data:, separated by blank lines. In an ideal world you'd just read line by line.

Three ways to read a stream: EventSource, WebSocket, or fetch

With the protocol settled, the question is how to get the stream into the browser. Three candidates.

EventSource is the browser's built-in SSE client, with automatic reconnection, and looks purpose-built for this. WebSocket is a full-duplex connection and sounds more capable. fetch with ReadableStream is manual transmission: you write everything yourself.

The answer is the third, and the first two are not merely worse — one is unusable and the other is overkill. The next section covers why the first one is out.

As for WebSocket: an agent interaction is "the user sends a request, the server streams back a series of events". That is request-response, not bidirectional realtime. Using a WebSocket means owning connection lifecycle, heartbeats, reconnection, and then layering request-response semantics on top of a persistent connection — a pile of state introduced for a fundamentally one-way scenario. Unless you genuinely need server-initiated push, such as multiplayer collaboration, it does not pay for itself.

Why fetch: EventSource's three hard limits

EventSource is genuinely pleasant to use:

JavaScriptJavaScript
// Looks lovely, and fails at step one in an agent scenario
const es = new EventSource('/api/agent')
es.onmessage = (e) => console.log(JSON.parse(e.data))

The trouble is three limits you cannot work around:

  1. GET only. But you need to send an entire conversation history, tens to hundreds of kilobytes of JSON. Stuffing it into a query string blows past length limits and writes the user's conversation into server access logs.
  2. No request body. The direct consequence of the above.
  3. No custom headers. No Authorization, so you fall back to cookie auth, which gets painful across origins.

So: fetch. It can POST, carry a body, and set headers, at the cost of writing SSE parsing, reconnection, and error handling yourself. Day six adds reconnection; today we get parsing working.

TypeScriptTypeScript
// One POST, then read the response body as a stream of bytes
const res = await fetch('/api/agent', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ threadId, runId, messages }),
  signal, // day six uses this for stopping
})
 
if (!res.body) throw new Error('response has no readable body')
 
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()

res.body is a ReadableStream of raw bytes. TextDecoderStream turns bytes into strings — and do not substitute your own TextDecoder decoding chunk by chunk, because a multi-byte character can land across a chunk boundary and per-chunk decoding will produce garbage there. TextDecoderStream holds the partial character over to the next chunk.

Passthrough and forward compatibility: handling events you do not recognize

One last design question, and a frequent interview one: your decoder receives an event type the protocol does not define. What should it do?

Three options: throw, silently ignore, or pass it up.

AG-UI's own schema answers this — its base event is passthrough, meaning unknown fields are preserved rather than rejected. The reasoning is forward compatibility: the protocol is still evolving and new events land regularly. If your frontend dies on anything new, then every backend release breaks every client that has not caught up.

So the right behavior is: ignore unknown event types rather than throwing, but log them in development.

TypeScriptTypeScript
function handleEvent(event: AgentEvent, ui: UiState) {
  switch (event.type) {
    case 'TEXT_MESSAGE_START':
      return ui.beginMessage(event.messageId, event.role)
    case 'TEXT_MESSAGE_CONTENT':
      return ui.appendDelta(event.messageId, event.delta)
    case 'TEXT_MESSAGE_END':
      return ui.endMessage(event.messageId)
    case 'RUN_ERROR':
      return ui.fail(event.message, event.code)
    default:
      // The point: skip what you do not recognize, do not throw
      if (process.env.NODE_ENV !== 'production') {
        console.debug('[agent] unhandled event type', event.type)
      }
  }
}

Source Reading

Hands-On Lab

🧪 D1 lab: a minimal chat page that renders an event stream

Code location: labs/frontend-agent-ux-7days/day-01-first-stream

Acceptance criteria:

  1. Clicking send makes text appear progressively rather than all at once when everything finishes
  2. The browser console shows the decoded event sequence, including RUN_STARTED and RUN_FINISHED
  3. Starting with INJECT=error shows an error message instead of spinning forever
  4. An unknown event type from the server does not break the page; the stream reads to completion
  5. MOCK=1 SELFTEST=1 pnpm start exits with code 0

Three of the files you write today get copied verbatim into the next six days, so they are worth doing carefully: the event types, the decoder, and the offline script. Confirm you are on Node 22 or later, then run pnpm install --ignore-workspace. If you get stuck, every key function in solution/ carries a comment explaining why it is written that way.

  1. Scaffold a Next.js app and write a route that emits events over SSE, verifying with curl first
  2. Define the event types this course uses, plus a type guard that keeps unknown types out
  3. Write the decoding iterator with fetch, ReadableStream, and TextDecoderStream, splitting on blank lines
  4. Accumulate TEXT_MESSAGE_CONTENT deltas into one message and render it
  5. Inject a RUN_ERROR and make the interface show an error state rather than hanging

Interview Questions

Three questions below, focused on choosing a streaming transport in the browser, the layering of an event protocol, and forward-compatible field handling. Open each one and read the analysis before the answer points — practicing the derivation beats memorizing bullets. The "common in China / common globally" tags let you filter by target market.

Checklist and Tomorrow

  • Name the three things that make agent interfaces hard, with a concrete failure for each that costs you the user's trust
  • Read the AG-UI event groups and explain what the run, message, and tool layers each solve
  • Read an event stream with plain fetch and ReadableStream, and explain why EventSource does not work here
  • Explain why decoding needs TextDecoderStream rather than per-chunk decoding
  • All 5 lab acceptance criteria pass
  • Answer at least 2 of the 3 interview questions without looking at the answer points

Tomorrow (D2) we put this minimal page under realistic load. When a model emits dozens to hundreds of deltas per second, today's approach of updating state on every delta sends React into a re-render frenzy, the page starts dropping frames, and even the input the user is typing into gets dragged down. Getting the protocol and decoder working first is deliberate: optimization has to start from a baseline you can measure, and without today's working version there is nothing to measure.

Interview questions

  • You need to consume a streaming response in the browser and the request carries a body. Would you use EventSource, WebSocket, or fetch with ReadableStream? Why?浏览器里要读一条带请求体的流式响应,EventSource、WebSocket、fetch 加 ReadableStream 你选哪个?为什么?
    Common in ChinaCommon overseasBasic#streaming#sse#browser-api

    How to reason about it · think before answering

    1. The tell is 'the request carries a body'. Candidates who miss that half of the sentence answer EventSource, since it looks purpose-built for SSE, and the interview largely ends there.
    2. Lay out the hard constraints first: EventSource is GET-only, cannot carry a body, and cannot set custom headers. WebSocket is a full-duplex long-lived connection. Fetch can do anything but hands you nothing.
    3. Map the constraints onto the scenario: an agent request POSTs a full conversation history, often tens to hundreds of KB, plus an Authorization header. EventSource fails on all three counts and is out.
    4. WebSocket would work but is the wrong tool: the interaction is request-response with a streamed reply, not bidirectional realtime. You would own connection lifecycle, heartbeats, and reconnection, and still have to layer request-response semantics on top.
    5. Land on fetch with ReadableStream, and volunteer the cost: SSE parsing, reconnection, and error handling are all yours to write.
    6. Expect the follow-up on reconnection: implement it yourself, track the last event id, send it on reconnect so the server can resume, and dedupe by message id rather than by content.

    分析过程 · 先想清楚再作答

    1. 题眼在「带请求体」四个字。没读到这半句的人会答 EventSource,因为它看起来天生就是读 SSE 的,这一答基本就结束了。
    2. 先把三个候选各自的硬约束摆出来:EventSource 只支持 GET、不能带请求体、不能自定义请求头;WebSocket 是全双工长连接;fetch 什么都能做但什么都要自己写。
    3. 再把约束对到场景上:Agent 请求要 POST 一整份对话历史(几十上百 KB)并带 Authorization 头,EventSource 的三条限制条条踩中,直接出局。
    4. WebSocket 能做但不该做:Agent 的交互是「发一个请求、流式收一串事件」,这是请求响应语义,不是双向实时。用长连接意味着要自己管连接生命周期、心跳、重连,还要把请求响应架在长连接上,为一个单向场景引入一堆状态。
    5. 结论是 fetch 加 ReadableStream,并主动说出它的代价:SSE 解析、断线重连、错误处理全都要自己实现,这正是要付的学费。
    6. 可预期的追问是「那断线重连怎么办」——答案是自己实现,记录最后一条事件的标识,重连时带上它让服务端续播,并且要按消息标识去重而不是按内容去重。

    Key points

    • Pick fetch with ReadableStream: EventSource is GET-only, takes no request body, and allows no custom headers, which rules it out for agent requests.
    • WebSocket is technically possible but semantically wrong here: this is request-response with a streamed reply, not bidirectional realtime.
    • The cost is owning SSE parsing, reconnection, and error handling yourself.
    • Decode with TextDecoderStream rather than per-chunk TextDecoder, or multi-byte characters split across chunk boundaries will come out garbled.

    答题要点

    • 选 fetch 加 ReadableStream,因为 EventSource 只支持 GET、不能带请求体、不能自定义头,三条都挡住 Agent 场景。
    • WebSocket 技术上可行但语义不匹配:这是请求响应加流式回复,不是双向实时,用长连接要多管一堆状态。
    • 代价是 SSE 解析、重连、错误处理全部自己实现,这是换来灵活性必须付的成本。
    • 解码时要用 TextDecoderStream 而不是逐块 TextDecoder,否则多字节字符被切在块边界上会解出乱码。
  • Why do protocols like AG-UI split one message into START, CONTENT, and END events instead of sending a complete message?AG-UI 这类协议为什么要把一条消息拆成 START、CONTENT、END 三个事件,而不是直接发一条完整消息?
    Common in ChinaCommon overseasIntermediate#protocol-design#streaming#ui-state

    How to reason about it · think before answering

    1. This probes whether you have actually built a streaming UI. Answering 'because it streams' just restates the question; the signal is whether you can name what the split buys the frontend.
    2. Start from the constraint: streaming means rendering before the sentence is finished, so the frontend must be able to represent 'this message is still being generated'. A single complete message cannot express that.
    3. Each of the three parts buys a concrete capability: START lets the UI create and reserve the message container so layout does not jump when content arrives; CONTENT carries only the delta, saving bandwidth and client-side work; END is an unambiguous completion signal.
    4. END is the most underrated: it is the only trigger for a pile of finishing work such as applying syntax highlighting, starting screen-reader announcement, revealing copy and regenerate actions, and persisting the message. Without it you are guessing with timeouts.
    5. Equally important, every event carries a message id. An agent may produce several messages concurrently, and without the id you cannot route deltas to the right one.
    6. Expect the follow-up about convenience CHUNK events that collapse all three: they spare a simple server from running a three-state machine, at the cost of precise control over start and end timing.

    分析过程 · 先想清楚再作答

    1. 这题考的是「你有没有真做过流式界面」。只答「因为要流式」是复述题干,区分度全在你能不能说出这个拆分让前端多做成了哪几件事。
    2. 推导的起点是一个约束:流式的本质是「话还没说完就得显示」,所以前端必须能表达「这条消息正在生成中」这个状态。一条完整消息做不到这件事。
    3. 拆成三段之后,每一段都换来一个具体能力:START 让界面先把消息容器建出来并占好位置,避免内容到达时布局跳动;CONTENT 只带增量,省带宽也省客户端拼接成本;END 是一个明确的完成信号。
    4. END 的价值最容易被低估,它是很多收尾动作的唯一触发点:补语法高亮、启动屏幕阅读器播报、显示复制与重新生成按钮、把消息落库。没有 END,前端只能靠超时猜,猜早了内容还没完,猜晚了界面一直显示在打字。
    5. 同样重要的是每个事件都带消息标识:Agent 可能并发产出多条消息(比如同时跑几个子任务),没有标识就无法把增量归到正确的那条上。
    6. 可预期的追问是「那为什么还要有 CHUNK 这种把三段合一的便利事件」——因为简单场景下服务端不想维护三段状态机,协议给了个捷径,代价是失去了对开始和结束时机的精确控制。

    Key points

    • Streaming means rendering before generation finishes, so the UI needs an explicit in-progress state.
    • START reserves the container so layout does not jump, CONTENT carries only deltas, END gives an unambiguous completion signal.
    • END is the only reliable trigger for finishing work: syntax highlighting, screen-reader announcement, copy actions, persistence.
    • The message id on every event is what lets you route deltas correctly when several messages stream concurrently.

    答题要点

    • 流式的本质是内容没生成完就要显示,所以界面必须能表达「正在生成中」这个中间状态。
    • START 让界面先建好容器避免布局跳动,CONTENT 只传增量省带宽,END 给出明确的完成信号。
    • END 是补高亮、启动朗读、显示复制按钮、落库这些收尾动作的唯一可靠触发点,没有它只能靠超时猜。
    • 每个事件带消息标识,才能在并发产出多条消息时把增量归到正确的那一条上。
  • Your frontend receives an event type the protocol does not define. Should it throw, ignore it, or pass it through? What drives your decision?你的前端收到一个协议里没定义过的事件类型,应该报错、忽略,还是透传给上层?说出你的判断依据。
    Common in ChinaCommon overseasDeep dive#forward-compatibility#protocol-design#error-handling

    How to reason about it · think before answering

    1. It looks like a small API design question but really tests whether you think about version skew. Answering 'throw, be strict' usually means you have never watched a backend release take down old frontends.
    2. There is one deciding question, and you can say it out loud: are the two sides released together? Inside one repo, strict failure is right and surfaces bugs early. Across teams with independent release cadences, strict failure turns 'backend added an event' into 'every older client crashes'.
    3. Agent protocols are the second case and are evolving fast, so be liberal in what you accept: ignore unknown event types and keep reading the stream, never throw.
    4. Ignoring is not pretending nothing happened. Log it in development so someone notices the upstream has moved; stay silent in production rather than polluting the user's console.
    5. The same rule applies at field level: keep unrecognized fields on a known event instead of stripping them. That is exactly what passthrough means in the schema.
    6. Expect the follow-up on what validation is still for: validate what you send, and validate required fields on events you do know. Tolerate unknown types, not malformed known ones.

    分析过程 · 先想清楚再作答

    1. 这题看起来是个 API 设计小问题,实际考的是你有没有版本演进的意识。答「报错,因为要严格校验」的人,通常没经历过后端升级把前端打挂的线上事故。
    2. 判断依据只有一条,而且可以直接问出口:这两端是同步发布的吗。同一个仓库里一起打包上线的,严格报错是对的,它能在开发期暴露问题;而协议两端由不同团队、不同节奏发布时,严格报错等于把「后端加了个新事件」变成「所有老前端崩溃」。
    3. Agent 协议属于后者,而且演进极快,上游随时在加新事件。所以正确策略是宽进:未知事件类型忽略掉,让流继续读完,绝不 throw。
    4. 但「忽略」不等于「装作没发生」。开发模式下要打一条日志,让开发者知道上游出了新东西该跟进了;生产环境静默即可,不要污染用户控制台。
    5. 这条原则在字段层面同样成立:一个事件里多出来没见过的字段也要原样保留而不是剥掉。这正是协议规范里 passthrough 的含义,也是所谓「宽进严出」在前端的落地。
    6. 可预期的追问是「那校验还有什么用」——校验用在你自己发出去的数据上,以及用在已知事件的必填字段上(比如文本增量事件缺了 delta 就该报错)。宽容的是未知类型,不是已知类型的坏数据。

    Key points

    • Ignore it and keep reading; never throw. With independent release cadences, strict failure breaks every older client each time the backend adds an event.
    • The deciding question is whether both sides ship together: same repo can be strict, independently evolving sides must be tolerant.
    • Log it in development so the drift gets noticed, stay silent in production.
    • Same at field level: preserve unrecognized fields instead of stripping them, which is what passthrough means.
    • Be liberal about unknown types, not about malformed known ones: a text delta event missing its delta should still fail.

    答题要点

    • 忽略并继续读流,绝不 throw:协议两端独立发布时,严格报错会让后端每次加事件都打挂老前端。
    • 判断依据是两端是否同步发布——同仓库一起上线可以严格,跨团队独立演进必须宽容。
    • 开发模式打一条日志提示上游有新东西,生产环境静默,不污染用户控制台。
    • 字段层面同理:未知字段原样保留而不是剥掉,这就是协议里 passthrough 的含义。
    • 宽容的对象是未知类型,不是已知类型的坏数据;已知事件缺必填字段仍然该报错。

Comments