JavaScript interactions.
Most assistant UIs only need declarative components plus reactive
$variables. But sometimes you want React-style effects,
Vue-style watchers, or a Svelte-style action — timers, animations,
clipboard, focus management, conditional fetch. That's what
JavaScript interactions are for.
The element ships with two built-in surfaces that turn Streaming UI Script into a full-fledged frontend framework:
Script("id", "body", deps?)
A behaviour-only node that runs the body when it mounts and again
whenever a listed $variable changes. Think
useEffect + watch in one statement.
@Js("code")
An action step you drop inside Action([...]) to run
JavaScript on click, submit, or any other user event.
ctx bridge
Both surfaces share a single ctx object exposing
reactive state, registered tools, DOM refs, lifecycle hooks, and
an AbortSignal for cancellable work.
JavaScript is part of the runtime
Script(...) and @Js(...) are wired up
automatically — no attribute to flip. The default ("full")
system prompt includes the JavaScript section so the LLM knows
when to use them. Use the chat-flavoured prompt
(getSystemPrompt({ mode: "chat" })) when you want the
model to stick to declarative components for short replies.
1. Drop in the renderer
No attribute needed — Script() and @Js()
always work. Calling getSystemPrompt() produces a prompt
that already teaches the model how to write scripts.
<streaming-ui-script
id="renderer"
theme="light"
></streaming-ui-script>
<script type="module">
import "https://asfand-dev.github.io/streaming-ui-script/dist/streaming-ui-script.js";
const el = document.getElementById("renderer");
// The default ("full") prompt includes the JavaScript interactions section.
const systemPrompt = el.getSystemPrompt({
preamble: "You are a UI assistant who can wire up small JS effects.",
});
</script>
Heads up: proxy elements
If you build the system prompt from a temporary<streaming-ui-script> element (a common pattern when
each turn renders into its own instance), just call
getSystemPrompt() — the JavaScript section is part of the
default ("full") prompt:
function buildPrompt() {
const proxy = document.createElement("streaming-ui-script");
return proxy.getSystemPrompt(); // includes JS interactions by default
}
const renderer = document.createElement("streaming-ui-script");
2. Writing the body string
The body argument of Script(...) and
@Js(...) is a string in Streaming UI Script. You can
write it two ways:
-
Backtick-quoted (
`...`) — multi-line strings with real newlines. Inner"and'are unescaped. Best for any non-trivial body. -
Double-quoted (
"...") — single-line strings. Escape inner"as\"and newlines as\n.
Inside the body, prefer single quotes for JavaScript string literals
('hello') so you never need to escape:
// Multi-line: backticks
oneOff = Script("oneOff", `
const value = ctx.state.get('count') ?? 0;
console.log("count is", value);
ctx.state.set('count', value + 1);
`)
// Single-line: double quotes
quick = Script("quick", "ctx.state.set('ready', true);")
3. The ctx bridge
Both Script bodies and @Js action steps
receive a single ctx argument. The surface is small and
framework-agnostic — there are no proxies or compile steps, just plain
JavaScript objects backed by the runtime.
| API | Purpose |
|---|---|
ctx.state.get(name) |
Read a reactive $variable. Use the bare name (no $). |
ctx.state.set(name, value) |
Write a reactive $variable. Triggers re-render and re-runs scripts depending on it. |
ctx.state.reset(...names) |
Restore variables to their declared defaults. |
ctx.state.values() |
Snapshot of every declared $variable at call time. |
ctx.tools.name(args) |
Invoke a tool registered via setTools(). Returns a Promise. |
ctx.dispatch(message) |
Fire an assistant-message event (same as @ToAssistant). |
ctx.open(url) |
Open a URL via the configured opener (defaults to window.open). |
ctx.query(id) |
Look up a rendered element inside the shadow root by its DOM id. |
ctx.queryAll(selector) |
CSS selector lookup, returning an array of matching elements. |
ctx.host |
The <streaming-ui-script> element itself. |
ctx.cleanup(fn) |
Register a cleanup callback. Runs before the next re-run and when the script unmounts. |
ctx.signal |
AbortSignal that aborts on re-run or unmount. Pass to fetch and other cancellable APIs. |
Script bodies are compiled with AsyncFunction, so
await works at the top level. Errors are caught and logged
— a broken script never crashes the host page.
4. Script lifecycle (the React mental model)
The renderer reconciles scripts after each render pass. The behaviour
mirrors React's useEffect:
- New
id→ run once. - Same
id+ samebody+ samedepsvalues → leave alone. - Different
bodyor any dep changed → cleanup, then re-run. - Script no longer present in the tree → cleanup, dispose AbortController.
The deps argument is an array of state variable names
(no $ prefix). Common shapes:
| Form | When the body runs |
|---|---|
Script("id", body) | Once on mount. |
Script("id", body, []) | Once on mount (explicit). |
Script("id", body, ["count"]) | On mount + whenever $count changes. |
Script("id", body, ["count", "title"]) | On mount + whenever either changes. |
5. Worked example: a live counter
A reactive counter that ticks every second when running, with cleanup that clears the interval on pause and on unmount.
$count = 0
$running = false
counter = Card([
CardHeader("Live counter", "Driven by Script()"),
TextContent("" + $count, "large-heavy")
])
controls = Buttons([
Button($running ? "Pause" : "Start", Action([@Set($running, !$running)]), "primary"),
Button("Reset", Action([@Reset($count)]), "secondary")
])
ticker = Script("ticker", `
if (!ctx.state.get('running')) return;
const id = setInterval(() => ctx.state.set('count', (ctx.state.get('count') ?? 0) + 1), 1000);
ctx.cleanup(() => clearInterval(id));
`, ["running"])
root = Stack([counter, controls, ticker])
6. Worked example: calling a tool from a script
Bypass Query/Mutation for one-off calls.
The script awaits the tool and writes the result back into state, so
the rest of the UI reacts as if it had come from a regular query.
$loaded = false
$repos = []
$error = ""
header = Card([CardHeader("Top GitHub repos", "Loaded via ctx.tools")])
status = $error == "" ? null : Callout("danger", "Couldn't load", $error)
list = $loaded ? Table([
Col("Repo", $repos.name),
Col("Stars", $repos.stars, "number"),
Col("Forks", $repos.forks, "number")
]) : Skeleton("table")
loader = Script("loader", `
try {
const data = await ctx.tools.list_repos({ limit: 5 });
ctx.state.set('repos', data.rows ?? []);
ctx.state.set('loaded', true);
} catch (err) {
ctx.state.set('error', String(err.message ?? err));
}
`, [])
root = Stack([header, status, list, loader])
7. Worked example: per-item click handlers (@Js(body, args))
When a button lives inside @Each(...), the loop variable
is a render-time local — it does not exist when the click
handler eventually fires. Use the optional second argument of
@Js to capture per-item data at render time:
$todos = [{id: 1, text: "Buy milk"}, {id: 2, text: "Walk dog"}]
list = @Each($todos, "t", row)
row = Card([Stack([
TextContent(t.text),
Button("Delete", Action([
@Js("ctx.state.set('todos', (ctx.state.get('todos') || []).filter(x => x.id !== ctx.args.id))", {id: t.id})
]))
])])
root = Stack([list])
The {id: t.id} object is evaluated per
iteration — every rendered button captures its own row's id.
Inside the body, read it as ctx.args.id.
Even better: skip JS entirely when you can
Many list mutations are pure data transforms and don't need JS at all. Delete becomes a one-liner with@Filter + @Set:
Button("Delete", Action([@Set($todos, @Filter($todos, "id", "!=", t.id))]))
Add becomes @Push + @Set:
Button("Add", Action([@Set($todos, @Push($todos, newTodo))]))
Reach for @Js only when no builtin can express the change
(e.g. toggling one field on one item).
8. Worked example: @Js for one-off side effects
Drop a single line of JavaScript into any action chain. Combine with
@Set, @Run, and @ToAssistant
for hybrid effects (e.g. write to the clipboard and tell the
assistant to acknowledge it).
$snippet = "npm install streaming-ui-script"
$msg = "Press Copy to test."
snippet = Card([
CardHeader("Copy to clipboard"),
CodeBlock("bash", $snippet),
Buttons([
Button("Copy", Action([
@Js("await navigator.clipboard?.writeText(ctx.state.get('snippet') ?? '');"),
@Set($msg, "Copied to clipboard.")
]), "primary"),
Button("Reset", Action([@Set($msg, "Press Copy to test.")]), "secondary")
]),
Callout("info", "Status", $msg)
])
root = Stack([snippet])
9. Worked example: cancellable fetch with ctx.signal
Pass ctx.signal into fetch (or any
AbortController-aware API). When the script unmounts or its deps
change, in-flight work cancels automatically — no stale state writes,
no leaked listeners.
$query = ""
$results = []
search = Script("search", `
const q = (ctx.state.get('query') ?? '').trim();
if (!q) { ctx.state.set('results', []); return; }
try {
const url = '/api/search?q=' + encodeURIComponent(q);
const res = await fetch(url, { signal: ctx.signal });
if (!res.ok) throw new Error(res.statusText);
ctx.state.set('results', await res.json());
} catch (err) {
if (err.name !== 'AbortError') console.error(err);
}
`, ["query"])
field = FormControl("Search", Input("q", "Type a query…", "text", null, $query))
list = List([@Each($results, "r", ListItem(r.title, r.summary))])
root = Stack([field, list, search])
10. Best practices
-
Prefer the declarative path. If
Action([@Set(...), @Run(...)])can express the behaviour, use it. Reach for JS only when there is no declarative equivalent (timers, focus, animations, custom DOM work, third-party APIs). -
Always cleanup. Register an
ctx.cleanup(...)for everysetInterval,setTimeout,addEventListener, subscription, or AbortController you create. Otherwise dependencies leak across renders. -
Treat scripts as effects. Don't throw, don't block.
For long work, wrap in
asyncand checkctx.signal.abortedbefore mutating state. -
Pass tools, not raw fetches. Register tools on the
host with
setTools({...})and call them viactx.tools.name(args). Keeping side effects declared on the host makes them observable from outside the element. -
Use stable ids. The reconciler keys on
Script("id", ...). If the LLM re-emits the same script with a different id, the runtime will dispose the old one and re-run from scratch, losing in-memory state. -
Mind the streaming window. Scripts never run while
streaming="true". Wait for the stream to finish before observing side effects.
11. Security model
The runtime uses new AsyncFunction(body) — the script
body runs in the same global scope as the host page. There is no
sandbox. That means:
- The script can access browser globals (
document,fetch,localStorage, …). - It cannot bypass the same-origin policy.
- It can only mutate the shadow DOM via
ctx.query; it does not get a reference to the parent document by default.
Treat Script(...) and @Js(...) like
dangerouslySetInnerHTML: only run them with model output
that you trust, and consider validating untrusted output server-side
(or rendering it inside an iframe sandbox) first.
Want even tighter control?
You can intercept scripts by wrapping setResponse /
appendChunk on the element and stripping any
Script(...) or @Js(...) lines before
they reach the runtime. Alternatively, switch to the
chat prompt flavour
(getSystemPrompt({ mode: "chat" })) so the LLM never
emits scripts for chat-style replies in the first place.
Comparison to other frameworks
The Script/@Js pair maps cleanly onto the patterns you
already know from popular frameworks:
| Framework | Closest equivalent |
|---|---|
| React | useEffect(fn, deps) — same dep array semantics, same cleanup model. |
| Vue 3 | watchEffect / watch(refs, fn) — Script() is the declarative equivalent. |
| Svelte | Reactive statements ($:) plus onMount/onDestroy. |
| Angular | effect() from Angular signals — Script() reads/writes the same store. |
| SolidJS | createEffect — automatic re-runs and disposal mirror SolidJS' fine-grained reactivity. |
Together with $variables, queries, mutations, and
actions, JavaScript interactions close the loop: anything you would
build in React, Vue, Angular, Svelte, or Solid can now be expressed
by an LLM in Streaming UI Script and rendered inside the shadow DOM.