Built-in

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:

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.

APIPurpose
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:

The deps argument is an array of state variable names (no $ prefix). Common shapes:

FormWhen 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

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:

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:

FrameworkClosest 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.