Built-in

Routing.

A tiny, hash-based router lets an LLM-authored UI span multiple pages. Navigation stays in sync with the URL (#/dashboard, #/users/42), browser back/forward works, and deep links survive a refresh — all without react-router or any framework-specific dependency.

Routes(items, default?)

Outlet that renders the matching Route child based on the current hash path. First match wins; falls back to default when nothing matches.

Route(path, content)

Declares one page. path supports literal segments, :param placeholders, and a trailing * wildcard.

NavLink(label, to, …)

Anchor that navigates without reloading the page and automatically reflects data-active="true" for the current path.

@Navigate("/path")

Action step for programmatic navigation. Use inside any Action([...]) chain to move after a save, submit, or follow-up.

Routing is always on

Every <streaming-ui-script> renderer starts the built-in router automatically. The default getSystemPrompt() output ("full" mode) includes the routing section so the LLM can confidently emit Routes / NavLink / @Navigate. Pick the lighter { mode: "chat" } prompt only when you want to skip routing-aware UIs for chat-style replies.

1. Drop in the renderer

No flags needed — the router is part of the runtime. Calling getSystemPrompt() on the element teaches the model how to wire Routes / NavLink / @Navigate.

<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");

  const systemPrompt = el.getSystemPrompt({
    preamble: "You are a UI assistant building a multi-page dashboard.",
  });
</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() — routing is part of the default ("full") prompt:
function buildPrompt() {
  const proxy = document.createElement("streaming-ui-script");
  return proxy.getSystemPrompt(); // includes routing by default
}

const renderer = document.createElement("streaming-ui-script");

2. Anatomy of a multi-page program

The canonical layout: a persistent navigation built from NavLinks, followed by a Routes(...) outlet that swaps in the matching page. The root only references named pages — every page becomes its own line that streams in independently.

root = Stack([nav, main])

nav = Stack([
  NavLink("Home",      "/",          "ghost", true),
  NavLink("Dashboard", "/dashboard", "ghost"),
  NavLink("Users",     "/users",     "ghost"),
  NavLink("Settings",  "/settings",  "ghost")
], "row", "s")

main = Routes([
  Route("/",           homePage),
  Route("/dashboard",  dashboardPage),
  Route("/users",      usersPage),
  Route("/users/:id",  userDetailPage),
  Route("/settings/*", settingsArea),
  Route("*",           notFoundPage)
], "/")

homePage       = Card([CardHeader("Welcome")])
dashboardPage  = Card([CardHeader("Dashboard")])
usersPage      = Card([CardHeader("Users")])
userDetailPage = Card([CardHeader("User " + params.id)])
settingsArea   = Card([CardHeader("Settings"), TextContent("Section: " + params._)])
notFoundPage   = Callout("warning", "Not found", "We couldn't find " + $route + ".")

3. Path patterns

Route patterns are matched left-to-right, one segment at a time. They are designed to be cheap to evaluate (no regex compilation, no allocator pressure) so swapping pages is essentially free.

PatternMatchesCaptures
"/" Only the root path.
"/about" Exact path #/about.
"/users/:id" #/users/42, #/users/jane. params.id
"/teams/:teamId/members/:memberId" Nested parameters. params.teamId, params.memberId
"/docs/*" #/docs, #/docs/guides/intro. params._ = remainder
"*" Anything (use as the final route for 404 / fallback). params._

4. Reactive surfaces

The router exposes two reactive surfaces that any expression can read:

SurfaceTypeWhere it's available
$route string Anywhere. Holds the current path (e.g. "/users/42"). The runtime owns it — never declare $route = "..." yourself.
params object Inside the matched Route's content only. Acts like an @Each loop variable: params.id, params._ (wildcard remainder), etc. Undefined outside that scope.

5. Programmatic navigation

Use the @Navigate action step inside any Action([...]) chain to push a new path. Combine it with @Run, @Set, or @ToAssistant for hybrid flows (save → navigate → notify):

saveBtn = Button("Save", Action([
  @Run(saveMutation),
  @Navigate("/dashboard"),
  @ToAssistant("Profile updated.")
]))

cancelBtn = Button("Cancel", Action([@Navigate("/dashboard")]), "ghost")

From plain JavaScript (e.g. integration tests, an outer chat UI, or a custom hotkey handler), call el.navigate("/path") on the element:

const renderer = document.querySelector("streaming-ui-script");
renderer.navigate("/users/42");

// Listen for hash changes (yours or the user's):
renderer.addEventListener("route-change", (event) => {
  console.log("now at", event.detail.path, event.detail.params);
});

6. NavLink behaviour

7. Worked example: deep-linkable user detail

A two-page mini app with a list view and a detail view. The detail page reads params.id straight from the URL, so a bookmarked #/users/42 renders exactly the same content whether the user arrived from the list, a typed URL, or the browser history.

$users = [
  {id: "ada",    name: "Ada Lovelace",     role: "Founding engineer"},
  {id: "grace",  name: "Grace Hopper",     role: "Compiler researcher"},
  {id: "lin",    name: "Lin-Manuel",       role: "Product designer"}
]

root = Stack([nav, main])

nav = Stack([
  NavLink("Home",  "/",      "ghost", true),
  NavLink("Users", "/users", "ghost")
], "row", "s")

main = Routes([
  Route("/",          home),
  Route("/users",     userList),
  Route("/users/:id", userDetail),
  Route("*",          notFound)
], "/")

home = Card([
  CardHeader("Welcome", "Pick a section from the nav"),
  Buttons([Button("Browse users", Action([@Navigate("/users")]), "primary")])
])

userList = Card([
  CardHeader("Users", "Click a row to deep-link"),
  Table([
    Col("Name", $users.name),
    Col("Role", $users.role),
    Col("",     @Each($users, "u", openRow))
  ])
])

openRow = Button("Open", Action([@Js("ctx.host.navigate('/users/' + ctx.args.id)", {id: u.id})]), "ghost")

userDetail = Card([
  CardHeader("User " + params.id, "Detail view"),
  TextContent("Role: " + (($users.find ? $users.find(u => u.id === params.id) : null)?.role ?? "—")),
  Buttons([
    Button("Back", Action([@Navigate("/users")]), "ghost")
  ])
])

notFound = Callout("warning", "Not found", "No page matches " + $route)

8. Worked example: settings with nested area

Combine a literal route with a trailing wildcard to model a nested section. The wildcard remainder lands in params._, so you can drive a secondary nav from the URL itself without redeclaring the route map.

root = Stack([nav, main])

nav = Stack([
  NavLink("Home",     "/",         "ghost", true),
  NavLink("Settings", "/settings", "ghost")
], "row", "s")

main = Routes([
  Route("/",           home),
  Route("/settings",   settingsHome),
  Route("/settings/*", settingsArea),
  Route("*",           notFound)
], "/")

home = Card([CardHeader("Welcome", "Open Settings, then click a sub-section")])

settingsHome = Card([
  CardHeader("Settings"),
  Stack([
    NavLink("Profile",       "/settings/profile",       "pill"),
    NavLink("Notifications", "/settings/notifications", "pill"),
    NavLink("Security",      "/settings/security",      "pill")
  ], "row", "s")
])

settingsArea = Card([
  CardHeader("Settings · " + params._, "Wildcard route"),
  TextContent("Sub-section: " + params._),
  Buttons([Button("Back", Action([@Navigate("/settings")]), "ghost")])
])

notFound = Callout("warning", "Not found", "No page matches " + $route)

9. Common mistakes

10. JavaScript API summary

APIPurpose
el.route Read-only string with the current path (e.g. "/users/42").
el.navigate(path) Programmatically navigate by updating window.location.hash.
route-change event CustomEvent fired on the element whenever the path changes. event.detail contains path, previousPath, params, and the matched pattern.

Where to next?