Talk to Your Datastar Chat: Voice Input with the Web Speech API
Extending the Datastar chat widget with the browser's native SpeechRecognition API — no new backend, no transcription service, just one more way to fill the same signal.
Read moreI wanted a dead-simple tool: paste a URL, get a clean Markdown summary streamed into the page. No page reloads, no React, no client-side state library. Just Astro on the server, Datastar for reactivity, and the Vercel AI SDK for the model call.
Here's how the pieces fit together.
There are two files:
That's the whole thing. No client framework, no build-time hydration island, no fetch glue code.
The page declares its state up front as Datastar signals, binds an input to url, and points a button at the action endpoint.
---
import Layout from '@/layouts/Layout.astro'
const initialSignals = JSON.stringify({
url: '',
_loading: false,
_error: '',
_streamChunk: '',
})
---
<Layout title="TLDR">
<section class="max-w-3xl mx-auto p-4" data-signals__ifmissing={initialSignals}>
<h1 class="text-3xl font-bold mb-2">TLDR</h1>
<p class="opacity-70 mb-6">Paste a URL and get an AI-generated summary.</p>
<input
type="text"
class="input input-bordered w-full mb-3"
placeholder="https://example.com/article"
data-bind="url"
/>
<button
class="btn btn-primary"
data-on:click="$_loading = true; $_error = ''; @get('/summarize')"
data-attr:disabled="$_loading || !$url.trim()">
<span data-show="!$_loading">Summarize</span>
<span data-show="$_loading">Summarizing…</span>
</button>
<div data-show="$_error" class="alert alert-error mt-4">
<span data-text="$_error"></span>
</div>
<div
id="summary"
class="prose max-w-none mt-6"
data-on-signal-patch-filter="{include: /^_streamChunk$/}"
data-on-signal-patch="window.__appendChunk?.(el, patch._streamChunk)">
</div>
</section>
</Layout>
<script>
import { default_renderer, parser, parser_write, parser_end } from 'streaming-markdown'
let smd: any = null
window.__appendChunk = (el: HTMLElement, chunk: string | undefined) => {
if (chunk == null) return
if (chunk === '__START__') { el.replaceChildren(); smd = parser(default_renderer(el)); return }
if (chunk === '__END__') { if (smd) parser_end(smd); smd = null; return }
if (smd) parser_write(smd, chunk)
}
</script>
A few things worth pointing out:
data-signals__ifmissing seeds the reactive state without clobbering it on re-renders.data-bind="url" keeps the input two-way bound to the url signal.@get('/summarize') tells Datastar to open an SSE connection to the action route, automatically sending the current signals along.data-on-signal-patch to react every time the server patches _streamChunk. We hand each chunk to streaming-markdown, which renders Markdown incrementally as tokens arrive — no flicker, no full re-parse on every chunk.The API route is where Datastar meets the Vercel AI SDK. Datastar's SDK gives us a ServerSentEventGenerator that handles the SSE protocol; the AI SDK's streamText gives us an async iterator of text deltas. We just bridge the two.
We also need a tiny extract helper that fetches the page and pulls out the readable text. Cheerio is enough for the 80% case — strip the chrome, prefer <article>/<main>, fall back to <body>.
// src/pages/summarize.ts
import type { APIRoute } from 'astro'
import { ServerSentEventGenerator } from '@starfederation/datastar-sdk/web'
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
import * as cheerio from 'cheerio'
async function extract(url: string): Promise<{ title: string; text: string }> {
const res = await fetch(url, {
headers: { 'user-agent': 'Mozilla/5.0 (compatible; TLDRBot/1.0)' },
signal: AbortSignal.timeout(15_000),
})
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`)
const html = await res.text()
const $ = cheerio.load(html)
// Strip chrome and non-content elements
$('script, style, nav, header, footer, aside, noscript, iframe, form, [role="navigation"], [role="banner"], [role="complementary"]').remove()
const title = $('meta[property="og:title"]').attr('content')?.trim()
|| $('title').text().trim()
|| $('h1').first().text().trim()
|| url
// Prefer semantic main-content containers, fall back to body
const candidates = ['article', 'main', '[role="main"]', '.post-content', '.entry-content', '.article-content']
let node = $('body')
for (const sel of candidates) {
const el = $(sel).first()
if (el.length && el.text().trim().length > 200) { node = el; break }
}
const text = node.text().replace(/\s+/g, ' ').trim().slice(0, 20_000)
if (!text) throw new Error('No readable content found at URL.')
return { title, text }
}
export const GET: APIRoute = async ({ request }) => {
const reader = await ServerSentEventGenerator.readSignals(request)
if (!reader.success) return new Response('Bad request', { status: 400 })
const url = ((reader.signals as any).url || '').trim()
if (!url) {
return ServerSentEventGenerator.stream(async (stream) => {
stream.patchSignals(JSON.stringify({ _loading: false, _error: 'Please enter a URL.' }))
})
}
return ServerSentEventGenerator.stream(async (stream) => {
try {
const article = await extract(url)
stream.patchSignals(JSON.stringify({ _streamChunk: '__START__' }))
const response = streamText({
model: openai('gpt-5'),
system: 'You are a concise summarizer. Output clean Markdown.',
prompt: `Summarize the following article in ~300 words:\n\n${article.text}`,
abortSignal: AbortSignal.timeout(120_000),
})
let pending = ''
let i = 0
for await (const delta of response.textStream) {
pending += delta
if (++i % 5 === 0 || delta.includes('\n')) {
stream.patchSignals(JSON.stringify({ _streamChunk: pending }))
pending = ''
}
}
if (pending) stream.patchSignals(JSON.stringify({ _streamChunk: pending }))
stream.patchSignals(JSON.stringify({ _streamChunk: '__END__' }))
} catch (err: any) {
stream.patchSignals(JSON.stringify({ _error: err.message || 'Something went wrong.' }))
} finally {
stream.patchSignals(JSON.stringify({ _loading: false }))
stream.close()
}
}, { keepalive: true })
}
The interesting bits:
extract is intentionally tiny: fetch with a timeout, strip chrome with cheerio, prefer <article>/<main>, cap the text at 20k chars so we don't blow the model's context. For sites that need real readability heuristics you'd reach for Mozilla's Readability, but this handles most blogs and news sites just fine.readSignals(request) pulls the current signal state out of the request that Datastar sent — so we get url server-side without writing any form-handling code.streamText from the Vercel AI SDK gives us an async iterator. We don't have to deal with raw SSE from the model provider — the SDK normalizes that.__START__ and __END__ let the client reset and finalize the streaming Markdown parser cleanly.I've built variations of this with React + a custom useChat hook, with htmx, and with raw EventSource. The Astro + Datastar + Vercel AI SDK version is the one I keep coming back to, because:
@get + patchSignals means I never write a fetch call or an EventSource listener. The server pushes signal updates and the DOM reacts.openai('gpt-4o') for anthropic('claude-sonnet-4-6') is a one-line change. The streaming interface stays identical.The whole thing is maybe 200 lines of code across both files. No state management library, no client router, no API client. Just a form, an SSE stream, and a Markdown renderer — exactly the amount of machinery the problem deserves.
Extending the Datastar chat widget with the browser's native SpeechRecognition API — no new backend, no transcription service, just one more way to fill the same signal.
Read moreTake the Astro + Datastar + Vercel AI SDK stack from this series and run it entirely on your own machine with Ollama — no API key, no per-token cost, no data leaving your laptop. It's a one-import change.
Read moreStream typed, structured data from the Vercel AI SDK and watch a card build itself field by field in a Datastar UI — using streamText with output, the v6 replacement for streamObject. No React.
Read more