Skip to content

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.

Live
sigmx agent
  • 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>
  • data-on:submit with @post('/api/chat'), and data-on:keydown so Enter sends while Shift+Enter inserts a newline
  • patchElements with mode: append for new bubbles, then patchElements by id for every streamed word
  • patchSignals for thinking, the word counter and clearing the draft
  • data-indicator, data-attr:disabled, data-show for the composer state
  • a second endpoint, /api/chat/stop, that flips a flag the stream checks between words; the client identifies its conversation with a chatId set by data-init
  • a custom server event (event: sigmx-tool) handled by a handler() plugin registered with sigmx.use()
  • data-scroll-into-view__smooth__vnearest on every appended bubble
  • data-persist so the unsent draft survives a reload
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 });
});
};
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>

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.