Chat agent
The message you type is posted with @post. The endpoint appends your bubble, appends an empty assistant bubble, then streams the reply one word at a time by re-sending the whole paragraph and letting the morph update it in place. Because each event carries the full text so far, a dropped or duplicated event changes nothing. Ask about the time or the weather to see a tool call: the server emits a custom sigmx-tool event and a handler registered on this page renders the card.
Hi! Ask me anything. Try what time is it, weather, or how does this work.
---import ToolHandler from './chat/ToolHandler.astro';---<div class="ex chat" data-signals="{ draft: '', thinking: false, turn: 0, words: 0, chatId: '' }" data-init="$chatId = crypto.randomUUID()" data-persist:chat-draft="{ include: /^draft$/ }"> <header class="bar"> <span class="avatar big" aria-hidden="true">σ</span> <div><b>sigmx agent</b><small class="muted"><span class="pulse"></span> <span data-text="$thinking ? 'typing…' : 'online · canned replies, real plumbing'"></span></small></div> <span class="chip" data-show="$words" data-text="$words + ' words'"></span> </header> <ul id="chat-log" class="log"> <li class="msg assistant"><span class="avatar" aria-hidden="true">σ</span><div class="bubble"><p>Hi! Ask me anything. Try <em>what time is it</em>, <em>weather</em>, or <em>how does this work</em>.</p></div></li> </ul> <div class="typing" data-show="$thinking" style="display:none"><span></span><span></span><span></span></div> <form class="composer" data-on:submit="@post('/api/chat')" data-indicator:sending> <textarea class="field" data-bind:draft rows="1" placeholder="Message… (Enter to send, Shift+Enter for a new line)" required data-on:keydown="evt.key === 'Enter' && !evt.shiftKey && (evt.preventDefault(), el.form.requestSubmit())"></textarea> <button class="btn round" title="Send" data-attr:disabled="$thinking || !$draft.trim()" data-show="!$thinking">↑</button> <button class="btn round stop" type="button" title="Stop" data-show="$thinking" style="display:none" data-on:click="@post('/api/chat/stop')">■</button> </form> <p class="hint muted" data-show="$draft.length > 0" data-text="$draft.length + ' characters · draft saved locally'"></p></div><ToolHandler /><style is:global> .chat { display: grid; grid-template-rows: auto 1fr auto auto auto; height: 30rem; background: var(--ex-surface); } .chat .bar { display: flex; align-items: center; gap: .7rem; padding: .7rem 1rem; border-bottom: 1px solid var(--ex-line); } .chat .bar b { display: block; font-size: .95rem; } .chat .bar small { display: flex; align-items: center; gap: .4rem; font-size: .75rem; } .chat .bar .chip { margin-left: auto; } .chat .avatar { display: inline-grid; place-items: center; width: 1.75rem; height: 1.75rem; border-radius: 50%; background: linear-gradient(135deg, var(--ex-accent), #7c5cff); color: #fff; font-weight: 700; font-family: var(--sl-font-mono); flex: none; } .chat .avatar.big { width: 2.4rem; height: 2.4rem; font-size: 1.1rem; } .chat .log { list-style: none; margin: 0; padding: 1rem; overflow: auto; display: grid; gap: .7rem; align-content: start; background: radial-gradient(circle at 100% 0, var(--ex-accent-low), transparent 45%); } .chat .msg { display: flex; gap: .5rem; align-items: flex-end; max-width: 82%; animation: msg-in .25s ease-out; } @keyframes msg-in { from { opacity: 0; transform: translateY(6px) } } .chat .msg.user { margin-left: auto; flex-direction: row-reverse; } .chat .bubble { position: relative; padding: .55rem .8rem .35rem; border-radius: 16px; background: var(--ex-surface-2); border: 1px solid var(--ex-line); } .chat .msg.assistant .bubble { border-bottom-left-radius: 4px; } .chat .msg.user .bubble { border-bottom-right-radius: 4px; background: var(--ex-accent); border-color: transparent; color: #fff; } :root[data-theme='dark'] .chat .msg.user .bubble { color: var(--sl-color-black); } .chat .bubble p { margin: 0; line-height: 1.45; } .chat .bubble time { display: block; font-size: .65rem; opacity: .6; text-align: right; margin-top: .2rem; font-variant-numeric: tabular-nums; } .chat .msg.tool { max-width: 90%; } .chat .msg.tool .bubble { background: transparent; border: 1px dashed var(--ex-accent); font-family: var(--sl-font-mono); font-size: .82rem; } .chat .tool-icon { font-size: 1.2rem; } .chat .who { display: block; font-size: .65rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ex-muted); } .chat .typing { display: flex; gap: .3rem; padding: 0 1rem .4rem; } .chat .typing span { width: .5rem; height: .5rem; border-radius: 50%; background: var(--ex-muted); animation: bounce 1s infinite; } .chat .typing span:nth-child(2) { animation-delay: .15s } .chat .typing span:nth-child(3) { animation-delay: .3s } @keyframes bounce { 0%, 60%, 100% { transform: translateY(0); opacity: .5 } 30% { transform: translateY(-.35rem); opacity: 1 } } .chat .composer { display: flex; gap: .5rem; align-items: flex-end; padding: .6rem 1rem 0; border-top: 1px solid var(--ex-line); } .chat .composer textarea { resize: none; border-radius: 18px; min-height: 2.5rem; } .chat .btn.stop { background: var(--ex-bad); color: #fff; } .chat .hint { margin: .3rem 1rem .6rem; font-size: .75rem; min-height: 1em; }</style>What it uses
Section titled “What it uses”data-on:submitwith@post('/api/chat'), anddata-on:keydownso Enter sends while Shift+Enter inserts a newlinepatchElementswithmode: appendfor new bubbles, thenpatchElementsby id for every streamed wordpatchSignalsforthinking, the word counter and clearing the draftdata-indicator,data-attr:disabled,data-showfor the composer state- a second endpoint,
/api/chat/stop, that flips a flag the stream checks between words; the client identifies its conversation with achatIdset bydata-init - a custom server event (
event: sigmx-tool) handled by ahandler()plugin registered withsigmx.use() data-scroll-into-view__smooth__vneareston every appended bubbledata-persistso the unsent draft survives a reload
Server side
Section titled “Server side”src/pages/api/chat/index.ts
Section titled “src/pages/api/chat/index.ts”import { readSignals, sseStream } from '@sigmx/astro/server';import type { APIRoute } from 'astro';import { experimental_AstroContainer as AstroContainer } from 'astro/container';import Message from '../../../components/examples/chat/Message.astro';
export const prerender = false;const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));export const stopped = new Set<string>();
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
const think = (prompt: string): { reply: string; tool?: { name: string; result: string } } => { const p = prompt.toLowerCase(); if (p.includes('time') || p.includes('clock')) return { tool: { name: 'clock', result: new Date().toLocaleTimeString('en-GB') }, reply: 'I checked the clock for you. The server says it is that time right now, streamed into a tool card by a custom server event.', }; if (p.includes('weather')) return { tool: { name: 'weather', result: 'Bristol: 17 °C, light rain, wind 12 km/h' }, reply: 'The weather tool answered. Everything you see arrived over one event stream: the tool card, this text, and the typing indicator that flips off at the end.', }; if (p.includes('sigmx') || p.includes('how')) return { reply: 'This chat is one form and one endpoint. Your message is appended by the server, then an empty assistant bubble is appended, and each word I produce morphs that bubble by id. The Stop button posts to a second endpoint that flips a flag the stream checks between words.', }; if (p.includes('size') || p.includes('bundle')) return { reply: 'The minimal build is about 5 KB gzipped, everything with the streaming client and morph is about 12.6 KB, and there are no runtime dependencies. Every number on the bundle-sizes page is measured from the real source.', }; return { reply: `You said “${prompt}”. I am a canned demo agent with no model behind me, but the plumbing is real: a streamed response, a morphed bubble, a typing indicator, a stop flag, tool cards, and a persisted draft. Try asking about the time, the weather, or bundle sizes.`, };};
export const POST: APIRoute = async ({ request }) => { const { draft = '', chatId = '', turn = 0 } = await readSignals(request); const text = String(draft).trim(); const container = await AstroContainer.create(); const render = (props: Record<string, unknown>) => container.renderToString(Message, { props }); return sseStream(async (s) => { if (!text) return; stopped.delete(chatId); const n = Number(turn) * 2; s.patchElements(await render({ id: `m-${n}`, role: 'user', text }), { selector: '#chat-log', mode: 'append' }); s.patchSignals({ draft: '', thinking: true, words: 0, turn: Number(turn) + 1 }); await sleep(300); const { reply, tool } = think(text); if (tool) { await sleep(400); s.send({ event: 'sigmx-tool', lines: [`name ${tool.name}`, `result ${tool.result}`, `after m-${n}`] }); await sleep(400); } s.patchElements(await render({ id: `m-${n + 1}`, role: 'assistant', text: '' }), { selector: '#chat-log', mode: 'append', }); const words = reply.split(' '); let out = ''; for (const [i, w] of words.entries()) { if (stopped.has(chatId) || s.closed) break; out += (i ? ' ' : '') + w; // Idempotent: the whole bubble is re-sent and morphed, so a dropped event is harmless. s.patchElements(`<p id="m-${n + 1}-text">${esc(out)}</p>`); s.patchSignals({ words: i + 1 }); await sleep(45 + Math.random() * 60); } if (stopped.has(chatId)) s.patchElements(`<p id="m-${n + 1}-text">${esc(out)} <i>(stopped)</i></p>`); s.patchSignals({ thinking: false }); });};src/pages/api/chat/stop.ts
Section titled “src/pages/api/chat/stop.ts”import { json, readSignals } from '@sigmx/astro/server';import type { APIRoute } from 'astro';import { stopped } from './index';
export const prerender = false;export const POST: APIRoute = async ({ request }) => { const { chatId } = await readSignals(request); stopped.add(String(chatId)); return json({ thinking: false });};src/components/examples/chat/Message.astro
Section titled “src/components/examples/chat/Message.astro”---interface Props { id: string; role: 'user' | 'assistant'; text: string;}const { id, role, text } = Astro.props;const time = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });---<li id={id} class={`msg ${role}`} data-scroll-into-view__smooth__vnearest> {role === 'assistant' && <span class="avatar" aria-hidden="true">σ</span>} <div class="bubble"> <p id={`${id}-text`}>{text}</p> <time>{time}</time> </div></li>src/components/examples/chat/ToolHandler.astro
Section titled “src/components/examples/chat/ToolHandler.astro”<!-- A custom server-event handler: the endpoint emits `event: sigmx-tool` and this renders a card. --><script> import { handler } from 'sigmx' const icons: Record<string, string> = { clock: '🕒', weather: '🌦️' } const tool = handler({ name: 'tool', handle(_runtime, { name, result, after }) { const card = document.createElement('li') card.className = 'msg tool' card.innerHTML = `<span class="tool-icon">${icons[name] ?? '🔧'}</span><div class="bubble"><span class="who">tool call · ${name}</span><p>${result}</p></div>` card.setAttribute('data-scroll-into-view__smooth__vnearest', '') const anchor = after && document.getElementById(after) anchor ? anchor.after(card) : document.getElementById('chat-log')?.append(card) }, }) ;(window as any).sigmx.use(tool)</script>Adapting it to a real model
Section titled “Adapting it to a real model”Replace think() with a call to your model’s streaming API and forward each token into the same loop. The client does not change: it only ever sees patch-elements events for one paragraph. If you want partial words to render as they arrive, send the accumulated text on every token; if you want to save bandwidth, batch tokens with a short timer.