An AI answer about markets starts going stale as soon as it’s written. “The stock is at 1,512, up 0.8%” is true at 10:42 and quietly wrong at 10:47, while the reader is still on the paragraph and deciding what to do about it.
Re-running the model to refresh a number is expensive and pointless. Having the model stream prices is worse. What you actually want is for the answer to stay a static document — the reasoning, the comparison, the conclusion — with a few small islands inside it that show the current value when one is available and say clearly when it isn’t.
Getting there is mostly about boundaries: what the model is allowed to mark as live, how that mark survives your markdown pipeline, and how many connections a page full of islands should open.
Make “live” explicit and closed
The first decision is to not detect prices in prose. It’s tempting — scan the text for “TICKER at 1234” and attach a ticker. It goes wrong in the most misleading way possible. A strike price, an analyst target, or last year’s close starts ticking live next to a sentence that was about something else.
So the model has to ask for a live value explicitly, with a small token you design:
{{price:NSE:INFY|last=1512.40|change=0.81|asof=2026-12-29T10:42+05:30}}
Keep the grammar closed. An exchange and a symbol, then a short, fixed list of attributes and nothing else. Drop values containing quotes, angle brackets, or backticks, and anything that looks like an event handler. Cap the number of islands per message so a runaway answer can’t open a hundred subscriptions. Historical numbers stay plain text.
The attributes in the token are a snapshot: what the model saw when it wrote the sentence. They matter later.
Normalise tokens on the server before the answer is stored, including any older formats you used to accept, so the client only has to understand one shape.
Get through the sanitiser instead of around it
If you render model output as markdown, you should be sanitising it. A good sanitiser strips unknown tags and blocks unknown URL schemes, which is exactly what makes it hard to sneak a custom element through. Every clever trick — a custom tag, a data: attribute, a special scheme — gets eaten, as it should.
So don’t sneak. Turn the token into something the sanitiser already allows: an ordinary relative link with a reserved path prefix.
// {{price:NSE:INFY|last=1512.40}} -> [INFY](/_live/price/NSE/INFY?last=1512.40)
A relative link passes through untouched. Then your markdown renderer’s link override recognises the reserved prefix and renders a live price component instead of an anchor. React builds the DOM. The model never supplies a tag, an attribute, or a handler. It supplies a string your parser accepted.
One subscription per conversation
An analytical answer can easily contain twenty islands, and a conversation can have several answers. A socket per chip would be absurd.
Put a provider at the conversation level. Each island registers what it needs, and the provider merges those into a deduplicated set keyed by EXCHANGE:SYMBOL and holds one subscription for all of them. A shape that works:
- The client periodically tells an authenticated endpoint what this conversation needs — say every 30 seconds, and again when the tab becomes visible — so demand expires on its own if the page is closed.
- It opens a WebSocket and has to send a subscribe message within a few seconds, or the server closes it.
- The server intersects the request with what that user is actually entitled to, sends a snapshot of current values first, then ticks.
- The client batches incoming ticks for a short window — 100 to 200 ms is plenty — before re-rendering, so a busy market doesn’t redraw the page thirty times a second.
You’ll probably use server-sent events elsewhere for the agent’s own progress, and it’s fine to use a WebSocket here. The two streams have different recovery semantics. Run events are a log: each one matters, they’re numbered, and a reconnect should resume from the last sequence. Prices are latest-value: if you missed ten ticks, you want the eleventh, not a replay. Choose the transport for the recovery story you need.
Live, snapshot, or nothing — and say which
Each island should be in exactly one of three states, and should show it:
const value = live?.last ?? snapshot.last ?? null;
const isLive = live?.last != null;
const asOf = isLive ? null : snapshot.asof;
A live value in normal colour. Without one, the snapshot from the token in a muted style, with its “as of” time. With neither, an em dash — never a zero. A missing price rendered as 0.00 doesn’t look like missing data. It looks like the stock collapsed.
Make the accessible label read as a sentence (“INFY 1,512.40 rupees, up 0.81 percent, live”) rather than a cluster of numbers. And when someone copies the answer, copy the values currently shown, flattened to plain text, not the raw token. Don’t feed live values back into the model’s context. They’re for the person reading, and they’d change the conversation’s prefix every second.
Embedded canvases shouldn’t open their own sockets
If answers can contain sandboxed HTML canvases, those canvases will want live values too. Don’t give them network access. Have the parent page forward ticks with postMessage, and have the script inside the frame check the sender:
window.addEventListener("message", (event) => {
if (event.source !== window.parent || event.data?.type !== "price-ticks") return;
repaint(event.data.ticks); // keyed by EXCHANGE:SYMBOL
});
Because the frame has an opaque origin, the parent can’t target it by origin, so the event.source check is what stops another window from feeding it fake numbers.
The bugs this design invites
These are the ones I’d test for before anything else, ideally in a real browser rather than a unit test, because they live in the seams between components:
- Same symbol, different venue. If your tick cache is keyed only by symbol, a tick from another exchange, or from a different account’s feed, can overwrite the observation the user selected. Key by exchange, and by account if entitlements differ.
- Any window can talk to the frame. Forget the
event.sourcecheck and any page that can get a reference to the frame can post prices into it. - Null becomes zero. Covered above. It deserves a test of its own.
- Symbols with odd characters. Tickers like
M&Mwill fall outside a naive token grammar, and the raw token text leaks into the answer. Decide the allowed character set from real exchange symbol lists, not from the alphabet. - Silent upstream starvation. If prices come from a poller with a batch limit, and demand exceeds the batch, an unstable slicing order can leave some symbols unpolled for minutes. Their islands keep showing an old value with nothing visibly wrong. Rotate the overflow in a stable order, and measure each symbol’s staleness.
What a good island still lacks
Two things I’d add before calling this finished. First, ordering: last-write-wins is fine with one feed per symbol, and wrong as soon as two feeds disagree. Carry the source’s own sequence or timestamp and refuse older values. Second, staleness: an island whose socket is open but whose ticks have stopped is the most dangerous state of all, because it looks live. After a timeout appropriate to the instrument, it should drop back to the muted “as of” display by itself.
The most important thing a live number can tell you is when it stopped being live. Everything else in this design is in service of that.