14. UI
Turning the Agent's Inner Monologue into an Interface Users Actually Understand: Streaming, Diffs, and Cards
The last post, on product flow, was mostly about "how the backend runs." But what a user actually touches with their own hands was never any of that flow — it's a page, an input box, a draft. This post covers how I translate the backend's precise, opaque agent inner monologue into plain language, so a non-technical writer can look at it and immediately understand what's happening, and pick it up with ease. This is the product's last mile.
🪧 The User Never Sees the Agent — Only a Product
Honestly, of every mechanism covered in the previous thirteen posts, the user perceives none of them, and shouldn't have to.
They open the app and see an input box, a piece being generated, a few lines of status text. And underneath that? Streamed messages arriving one after another, tool calls firing repeatedly, permission approvals popping up now and then, purely internal concepts like draft.md. Hand that "inner monologue" straight to a writer, unfiltered, and it might as well be an unreadable wall of jargon — enough to scare them off entirely.
So the entire job of the UI layer is really translation: taking the agent's inner monologue and rendering it into language and interaction a writer already knows. And that translation has to be calibrated carefully — it has to make the user feel like "this thing is genuinely working hard for me" (in control, explicable), without drowning them in underlying detail (not scary, not distracting). This post walks through how I translated each category of inner monologue a user actually runs into.
🎭 The Overall Frame: The UI Is a Translation Layer for the Agent's Inner Monologue
Right now, somewhere in the backend, the agent is running turns, streaming text deltas, calling tools, waiting on your approval — all of that is its inner monologue. What the user actually wants is the feeling of "I'm collaborating smoothly with an assistant that genuinely gets writing." The UI's entire job is translating between the two.
Can a writer who's never heard of "agent," "prompt," or "token" pick this product up entirely on instinct and use it comfortably? That's the real bar. The seven translations below are all aimed at exactly that.
📡 Translation One: Two Layers of Information Flow — Letting Users See Both "It's Writing" and "What It's Doing"
While the agent works, two fundamentally different kinds of information are actually streaming out of the backend at once: one is the text delta of it reasoning and thinking through the piece; the other is structured progress events — "starting to call a tool," "the tool returned," "searching the collection," "just auto-tidied the conversation."
I present these as two separate layers, each with its own job:
- Layer one, character-by-character typing. Render the reasoning process out, character by character, giving the user the immediate warmth of "someone is thinking this through for me right now." This layer's value is presence — a cold, spinning loading indicator and text visibly appearing line by line, in real time, feel worlds apart, and the latter is what "collaboration" is actually supposed to feel like.
- Layer two, progress cues. Translate those structured events into a plain-language status line — "searching collection material...", "just auto-tidied the conversation for you." This layer delivers "explicability" — letting the user know what it's actually busy doing, instead of staring at a silent wait and guessing.
Backing both layers is a strictly aligned event protocol between the backend and frontend (technically, it runs over SSE). The backend translates whatever's happening into a set of pre-agreed events, pushed to the frontend: text_delta (a text chunk), tool_start / tool_result (a tool starting or finishing), status, todo_update, ask_question (a clarification), approval_request, done. This protocol is the skeleton of the whole interaction — if either side changes it without telling the other, the UI breaks.
There's a balance to strike here: layer two's progress shouldn't just dump raw tool names and arguments in front of the user. The tool's actual name on the backend is mcp__publish__to_mowen — show that to a user, and they'll just be confused. It needs to be translated into "publishing to Mowen..." That back-and-forth translation — raw name in, plain language out — is exactly where this layer earns its keep.
📝 Translation Two: The AI Produces New Content — Keeping the User Feeling in Control
The second category of inner monologue is about "the AI just produced new content." I settled on one interaction rule here: the AI's output updates the draft in the right-hand panel directly, but the user can click "edit" at any time to enter manual edit mode and take over. And I made "editing" and "conversation" mutually exclusive — while you're editing the draft, the AI never interjects; while the AI is writing, you can't accidentally scramble its output either. This traces directly back to post 13's EditSession design: the chat box gets disabled while edit mode is active, and a diff only gets settled once you exit editing.
Why not have the AI's output go through a "diff preview, accept item by item" flow instead? I considered that and dropped it: writing is a bit of a special case — what the AI produces is usually a large, complete block of new content, not a scattering of small edits to existing text. Reviewing that diff-by-diff would just leave the user exhausted, clicking "accept" on every single sentence. Better to let it write the whole thing out, and if the user reads it and something feels off, they go into edit mode and fix it themselves — that's a lot closer to how an experienced writer actually works with a junior collaborator in real life: the agent hands over a draft, and you take it from there.
That said, diff review does have a place — it's genuinely well-suited to somewhere else: profile-update review. Every time the backend finishes analyzing your editing habits and produces a "here's a suggested profile change," that's exactly where diff earns its keep. Each suggestion is a precise, small edit to the profile (add one preference, tweak one phrasing), and showing it as an item-by-item +/- diff, accepted or rejected one at a time, fits perfectly. A draft's output wants fluidity; a profile update wants precision — two different scenarios, two different treatments.
There's one more key decision in the information architecture here, drawing the line between "the piece" and "raw material": the draft in the right-hand panel is the "finished piece" the AI produces for this task; anything you upload, paste in as a long draft, or type as an instruction is "context material" — it enters the agent's head, but it never lands directly in that right-hand panel's draft.
🃏 Translation Three: Approval Cards + Clarification Cards — Folding Two Kinds of "Interruption" Into One Card
The third category of inner monologue is the agent needing to "stop and wait for your response." There are two flavors of this, and they're fundamentally different mechanisms:
- Approval (from post 9's
canUseTool): it's about to do something that burns real tokens — a high-frequency web call — and the rules say pop up a card and ask first: "allow this once / always allow / deny." - Clarification (from post 5's
AskUserQuestion): it's unsure about your intent and wants to ask a multiple-choice question, like "do you want this piece sharper, or gentler in tone?"
One's a safety gate, the other's an intent check — completely different underlying mechanisms. But from the user's perspective, they're the exact same thing: "the interface has paused, please make a choice." So I let them share the same multiple-choice card UI — the user only ever has to learn one behavior, "look at the card, pick an option," and it applies everywhere. This is subtraction, applied to interaction: don't make the user learn the same mental model twice when it can be merged into one.
On clarification specifically, I added one more piece of custom handling:
🔧 Gotcha · the model loved asking questions a little too much, so I added a filter gate
The clarification tool is genuinely useful, but it's also easy for the model to overuse. In testing, it occasionally interrupted with things that shouldn't have gone to the user at all — asking "should the fact-check results go into your piece?" (already settled by the product's own default flow), or asking about very basic writing-flow mechanics (the user cares about the result, not the steps in between). Getting interrupted by dumb questions like this repeatedly makes for a genuinely bad experience.
So I added a clarification-question filter gate on the backend: every clarification the model throws out first passes through a quality check, and anything low-value — "this already has a default answer," "this is just asking about basic process" — gets caught and never reaches the user as a popup. This gate works hand in hand with post 5's system prompt rule (Default-then-Clarify: act on the default, only ask when it's genuinely necessary) and post 7's output contracts: the system prompt talks the model out of asking unnecessary questions at the source; the filter gate catches whatever slips through at the end — two layers of insurance, so the user never gets nagged by a low-value question.
🛡 Translation Four: Internal Terms Stay Internal — Users Should Never See "draft.md"
The fourth category isn't really "translation" so much as "muting": completely silencing internal jargon that was never meant to reach the user's ears.
This product cleanliness came up in posts 7, 11, and 13 already — the UI layer is where it reaches its final destination: nothing in the conversation, nothing on screen, should ever say draft.md, "draft panel," CLAUDE.md, "profile block," or any other technical term — everything is just "your piece," "your writing style."
Leaking internal concepts has real, direct costs: it scares off non-technical users — a writer seeing "draft.md" on screen for the first time thinks, "what is this? Did I install the wrong software?"; and it looks unpolished — the user should only ever be dealing with familiar writing concepts like "the piece," "the style," never my backend's own file system.
And this isn't something you can fix purely at the UI text layer. Conversation content is generated by the model — hiding it neatly on screen does nothing if the model itself blurts out "I've already updated your draft.md" mid-reply. So this rule has to be enforced starting from block 8 (Tone & Output Format) of post 5's system prompt, guiding the model to never say these internal terms in conversation either — only with both ends consistent does the illusion actually hold. A tiny detail like "no leaked jargon" looks trivial and obvious on its face, but behind it is a full chain, running from the system prompt all the way to UI copy.
⌨️ Translation Five: The Care Hiding in the Small Details
A few experience details are unremarkable individually, but skip any one of them, and using the product ends up with a small, nagging feeling of friction. A few worth calling out:
IME misfires — a gotcha specific to the Chinese-language context. A Chinese-speaking user types English into the input box (say, "admin"), hits Enter to confirm the autocomplete candidate — and the message gets sent by accident. That's because the browser's Enter keydown, fired when you're confirming a candidate character, is indistinguishable from a normal Enter — the send logic sees "Enter" and fires the message.
📐 Going deeper · why this Enter "misfires," and how to fix it for good
The root cause is the input method's (IME) composition mechanism. The process of typing pinyin and selecting a candidate is called "composing." Per the W3C spec, the
keydownfired when you confirm a candidate happens before the "composition end" event, and carries a flag:isComposing === true— meaning "this keypress is for confirming an IME candidate, not for triggering your app's logic."The real fix is checking, at the very start of every "Enter submits" keyboard handler: if this keypress is still inside IME composition (
isComposing), just return and do nothing. Older browsers don't have this flag, so there's an old fallback —keyCode === 229. And this has to be caught at thekeydownstage specifically (too early and there's no information yet; too late and the message has already been sent).I collapsed this check into one shared utility, and every input field (login, writing, draft) follows the same pattern: the very first line of the keyboard handler checks for IME composition state first. This kind of gotcha — something Chinese-speaking users hit daily, but that off-the-shelf open-source components never account for — is exactly the sort of thing you have to solve yourself when localizing a product.
Inline banners, not toasts in the corner. For form-style success/failure feedback (renamed something, saved a setting), I default to an inline notice bar right next to the button you clicked (auto-dismissing after a few seconds), instead of a toast flying off to the bottom-right corner. Because the user's eyes are on the button they just clicked, right now — feedback should show up exactly where their eyes already are. A toast that flies to the bottom-right might not even register for a new user. I reserve toasts for feedback that's genuinely global and unrelated to any specific action location — a welcome message after signing in, say.
Never hang interaction logic off a disabled button. A disabled button's click event never fires. I once hung a "you haven't picked a genre yet" hint on a "Next" button's click handler, while also setting that same button to disabled because no genre had been picked. The result: the two conflicted directly. With no genre picked, the button just sat there gray and unclickable, and that hint never had a chance to fire, ever. The broader lesson underneath this: every interaction entry point needs correct behavior across every possible state it could be in beforehand — no blind spots left uncovered.
✍️ Translation Six: A Genuinely Good WYSIWYG Editor
For a writing product, the editor matters a great deal. There's some personal taste involved here too — I have a real preference for the smooth, "what you see is what you get, rendered as you type" feel of something like Typora — not a split pane with Markdown source on the left and a preview on the right, and definitely not a bare text box.
Getting there took a fair amount of work integrating open-source pieces:
🔧 Gotcha · picking the editor took a few wrong turns
I originally chose an editor called Milkdown (built on ProseMirror). It looked great on paper, but once I actually built a minimal proof of concept, its architecture turned out incapable of the genuinely instant rendering I wanted — the experience fell short.
That put a hard decision on the table: settle for what I had, or start over? I chose the latter — a clean cut over to Vditor, an editor with an actual IR (instant-rendering) mode, dropping 6 Milkdown packages entirely and migrating both the writing panel and the draft modal.
There's a small lesson worth keeping in mind: for a product's core experience assumption (something like "can the editor genuinely be WYSIWYG"), validate it early with the smallest possible spike — don't wait until the implementation is deep and the code is tangled to discover the foundation was wrong. A small boat is easy to turn; a big ship isn't.
Once the editor was picked, there was still a lot of integration work needed to actually get the writing experience feeling right. A few examples:
- Serialization, kept separate inside vs. out. Internally, the editor renders "what you see is what you get" using its own DOM representation; but on disk, I only ever store clean Markdown, with a conversion engine translating both directions in between. That way, the piece's file is always portable, readable, plain Markdown — never hostage to the editor's own proprietary format.
- Silent autosave, feedback on exit. Autosave runs debounced while you're editing (paired with post 13's EditSession), staying out of your way — no "saving..." popup interrupting your train of thought. But when you manually click "exit edit," it gives you explicit success/failure feedback, so you know for sure it's settled. Switching tabs also forces an immediate confirmation.
- Three entry points for images, one gotcha. Paste, drag-and-drop, and clicking a button — I unified all three into a single handler. There's a real gotcha here: pasting an image has to intercept the editor's own paste handling, or it converts the image into a long base64 string and dumps it straight into your Markdown, thoroughly polluting the draft.
- Even image captions needed to be editable. I store a caption inside the image's
altattribute (zero-intrusion — doesn't break the Markdown standard), and dynamically inject a clickable caption-editing region while editing.
None of these details are things a user's likely to consciously notice, but none of them could be skipped either — piled up together, they're what makes an editor actually feel right to use. Even today, using this in heavy daily rotation, I keep discovering edge cases that weren't quite handled — the only way through is dogfooding it constantly and iterating fast.
That last one — "even the image caption has to be editable" — is a genuinely representative example, and it took over a dozen rounds to get right. Worth pulling apart on its own, because it's such a clean illustration of "how do you coexist with an assertive third-party library":
📐 Going deeper · why one little caption box took a dozen rounds to get right
The difficulty was never the feature itself — it was that the WYSIWYG editor I'd picked (Vditor's instant-rendering mode) seemed almost hostile to the idea of "insert my own input box into content it's rendering." A dozen rounds of back-and-forth later, I finally figured out its temperament.
First layer of hostility: it gives you no hook to intervene through. I wanted a clickable caption-editing region to appear under each image — the normal move is using whatever "render hook" the editor exposes, except its instant-render mode simply doesn't have one. The fallback was attaching a
MutationObserver— a DOM-change listener — and forcibly "patching" my caption box in during the gap right after every re-render.Second layer of hostility, and the nastiest one: it kept destroying whatever I'd injected, over and over. It watches your input closely — every character you type, it tears down the entire block's DOM and re-parses, re-builds it from scratch. My carefully injected caption box happened to sit right inside the territory it manages, so every single keystroke got it swept away along with everything else. I fought this head-on with interception attempts for several rounds and none of them worked, until it clicked: stop fighting it — make it unable to perceive me at all. I marked my own input as "this is not editable content," and cut off the input events it bubbled up right at the very first stop, and the editor stopped treating it as part of its own territory to rebuild.
And the version that finally shipped had a kind of back-to-basics elegance to it. I'd originally been calculating, in JavaScript, exactly where that input box should float and how it should scroll along with the image — a genuine headache to get right. Eventually I just made it a DOM child element of the image caption itself — a child element naturally follows its parent, and scrolling and positioning needed zero lines of JavaScript at all.
This whole ordeal taught me two things. One, going from fighting it to going with it: facing down an assertive third-party library, rather than blocking it at every turn and wrestling with it, it's often better to hide your own elements outside its perception entirely — stay out of each other's way. Two, don't trust an automated test's "pass" at face value: for this kind of fiddly work inside a
contenteditableregion, browser-simulated keyboard input and a real person typing character by character run down two genuinely different code paths — I got fooled by "already tested" more than once, and in the end, the only thing that actually counted was manually re-verifying with a real keyboard.
🔄 Translation Seven: Running Multiple Tasks — You're Switching Tabs, Not Killing a Process
The last category of inner monologue was the most deeply hidden. It never shows up during a single writing task — it only ambushes you with a nasty "surprise" the moment you have two writing tasks open at once and switch between them.
Here's what happened. From the product design stage, multiple concurrent writing tasks were always meant to be supported — you're deep into one long piece, suddenly remember another one needs a closing paragraph, switch over, make a couple of tweaks, switch back — perfectly reasonable. The backend genuinely already supported multi-task concurrency: every task had its own SDK client, its own working-directory lock, its own SSE stream — two agent subprocesses could genuinely run in parallel.
But the frontend got left behind. Early on, the frontend only modeled "a single current session" — every piece of state (chat history, approval cards, to-dos, draft edit state... over twenty of them) hung off one component instance. Every time a task switched, this code would wipe all of that state in one shot, then immediately kill the SSE connection. The cascading fallout: on the backend, the moment the connection dropped, the agent subprocess that was still running got killed too; switch back, and the history couldn't be pulled back (because the process died before that turn ever made it to disk), and the draft was gone too. The user just switched a tab, and the backend experienced a process massacre.
That's not even the worst of it. I also hit a nastier data-corruption bug: switching tasks needs to settle the previous task's edit session, but a React closure was capturing the new task's ID by the time that ran — so the old task's edited content got written into the new task's draft, overwriting the new task's already-saved draft. Switch back, and the carefully written piece had turned into the content of a completely different one.
The root cause was simple, when you name it: the backend was already a multi-task concurrent architecture, and the frontend was still stuck in a "single task, switching means resetting" model. This gap is exactly the kind of hidden chasm between "an agent product that runs" and "an agent product that's actually usable." A generic agent demo usually only shows off a single task, so this problem can go entirely unnoticed — but ship it as a real product, and users will inevitably open more than one, and the moment they do, it breaks.
The fix had two layers. Frontend: introduce per-session state caching — each task's state is kept independently, and switching means "switch the view," not "wipe and rebuild." Type half a sentence into task A's input box, switch to task B, switch back to A, and that half-sentence is still there. Backend: decouple the SSE stream from the HTTP connection — an agent turn keeps running independently in the background, with events written into a per-task event buffer; a frontend disconnect only unsubscribes, never kills the process, and switching back resumes with a stream_id, catching up on whatever events were missed while it was switched away. Together, these two layers actually deliver "you're switching a tab, not killing a process."
This resume mechanism itself took two more rounds of bug fixes to get right:
📐 Going deeper · React state can't be trusted under async race conditions — cross-render state recovery needs a ref instead
The hard part of resuming is: when you switch back to a task still running in the background, how do you route the resumed events to the right place in the UI? My reducer relies on a
currentTurnIdto know "which turn should events get appended to right now," and that ID lived in React state.The first fix had switching back search
state.items(the message list) for a currently-streaming turn to recovercurrentTurnId. That worked intermittently, because switching back also asynchronously pulls history — and once that history comes back, it dispatches ahydrateaction that wipes and rebuilds the entire list, andhydratehappened to clearcurrentTurnIdback to null along with everything else. So resumed events could arrive right around thathydratewindow,currentTurnIdwould be null, and the events would get silently dropped, freezing the UI.The second round is where the actual root cause surfaced: React state can't be trusted under async race conditions.
hydrateis async, and there's no guaranteed ordering between it and resumed events arriving; and the instanthydrateclearsstate.items, searching an empty list obviously finds nothing. The real fix was auseRef(aMap<taskId, turnId>) — a ref stays stable across renders, immune to hydrate race conditions. When sending a message, the turn ID gets stored into the ref synchronously first, then dispatched into state; when resuming, read from the ref first, and only fall back to state if needed.State that needs to survive across renders and async operations shouldn't lean on React state (it can get wiped by hydrate or race conditions) — persist it in a ref instead. State is for rendering; a ref is for logic.
Once this translation was done, multi-task concurrency actually became usable. Open three or five writing tasks, switch between them freely, each one's backend agent runs independently, and switching back resumes cleanly — nothing lost.
⚖️ Where This Gets Vertical: The Last Mile Is Where "Vertical" Really Gets to Flex
To close out, the full comparison table:
⚖️ The tradeoff · facing the agent's inner monologue, "works" and "works well" are two different attitudes
The agent's inner monologue The generic approach (a developer's view) SmartWriter (a user's view) Message stream Dump the raw stream in front of the user Two layers: character-by-character typing + plain-language progress AI output Overwrites directly, user passively accepts Written directly but always editable, editing and conversation mutually exclusive Interruptions A different popup for every case Approval / clarification share one card + dumb questions filtered out Internal concepts Exposed as-is (draft.md...) Fully muted, always just "your piece" Localization details Frequently overlooked IME misfire protection, inline banners, no gaps in the state machine Multi-task concurrency Single-task demo, breaks the moment you open more Per-session caching + stream/HTTP decoupling, switching never kills a process
An interface for an agent that merely "works" is often built from a "developer's view" — it lays out more or less everything happening in the backend, unfiltered, because to a developer, that information is useful. An interface for an agent that "works well" needs to shift entirely to the user's view — the user doesn't care about the stream, doesn't care about tool calls, and has certainly never heard of draft.md. All they want is to write their piece smoothly. Not one of the translations in this post — the two-layer information flow, editing and conversation mutually exclusive, shared cards, muted internal terms, IME misfire protection, the WYSIWYG editor, multi-task concurrency without lost state — comes free out of the SDK. Every single one had to be laid down, inch by inch, on this last mile.
Which brings this back around to my friend's question from the prologue: "isn't this just a wrapper?" By this last post, I think I can finally answer that properly: that "wrapper," from the engine at its very core all the way out to the steering wheel and the dashboard, is real, hard-earned work at every single layer. "Wrapper" might not be the wrong word — but it needs "carefully built" in front of it, to actually do justice to everything that went into it.
That closes out fourteen posts of substance. We started at the agent's heartbeat, worked through its memory, its persona, its tools, its permissions, its orchestration, and landed, finally, on the page a user's fingers actually touch. Twelve mechanisms, two verticals — the whole thing, taken apart, start to finish.
Next post is the finale. I want to step back and look at this whole machine assembled, as one piece: what actually makes it something other than "just another generic agent" — something genuinely built into the business of writing? Let's keep going.