ui_apps: Reactive Dashboards
Ship live HTML dashboards bound to your agent's SQLite memory. The iframe re-renders automatically as your agent writes data - no polling, no refresh button.
Overview#
ui_apps renders arbitrary sandboxed HTML bound to the agent's SQLite memory. The harness injects a small window.rush SDK that exposes sql.query / sql.subscribe / tool.call. As the agent writes to its memory.db, the iframe re-renders automatically - the data dependency is the refresh trigger.
Use this when generative UI components aren't enough - when you need a calendar heatmap, a custom chart, a domain-specific dashboard, or anything that calls for D3, Recharts, or hand-rolled SVG.
ui_apps vs ui_components#
| Use ui_components when... | Use ui_apps when... |
|---|---|
| Standard primitives (email card, metric card, form) | Custom visualizations (heatmap, dashboard, chart) |
| Type-safe Zod schema, validated at LLM boundary | Free-form HTML/CSS/JS authored by you |
| Per-component action handlers wired to tools | Live data binding to the agent's SQLite memory |
| You want the registry to enforce a contract | You want React / D3 / anything inside a sandbox |
The two coexist. An agent can ship both - a ui_components.email_card for triage AND a ui_apps.inbox_heatmap for the long-term picture.
Declaring a ui_app#
Declare a sqlite table (data source) and a ui_apps block (presentation) in agent.yaml:
tools:
- name: sqlite
config:
tables:
posts:
columns:
id: { type: integer, primary_key: true }
agent: { type: text, required: true }
date: { type: text, required: true }
title: { type: text }
ui_apps:
cadence_dashboard: # app name -> tool "ui_app_cadence_dashboard"
title: "Publishing Cadence"
html: ./apps/cadence.html # or `inline: |` for tiny widgets
tools_allowed: [sqlite] # whitelist for window.rush.tool.call
permissions:
camera: false
microphone: false
geolocation: false
csp:
connect_domains: [] # default 'connect-src none'
Fields
| Field | Type | Purpose |
|---|---|---|
| title | string | Shown in the iframe chrome and as the tool description |
| html | string (path) | Path to HTML file, relative to agent dir. Mutually exclusive with inline. |
| inline | string (HTML) | Inline HTML for tiny widgets. Mutually exclusive with html. |
| tools_allowed | string[] | Whitelist for window.rush.tool.call. Defaults to sqlite-only. |
| permissions | object | Iframe sandbox capabilities: camera, microphone, geolocation. allow-same-origin is never granted. |
| csp | object | Content-Security-Policy overrides. Defaults to connect-src 'none' - no outbound network. |
The window.rush SDK#
The host injects window.rush into every ui_apps iframe at load time - same pattern as the existing CSP <meta> injection. Three top-level namespaces:
interface RushUiApp {
sql: {
query(sql: string, params?: any): Promise<Row[]>;
subscribe(
sql: string,
params: any,
cb: (rows: Row[]) => void
): Subscription;
};
tool: {
call(name: string, params: any): Promise<any>;
};
ui: {
sendMessage(text: string): void;
requestDisplayMode(mode: 'inline' | 'fullscreen'): void;
};
}
interface Subscription { unsubscribe(): void; }sql.subscribe is the load-bearing primitive. It parses the SQL string to extract referenced tables, issues an initial read-only query through ui/sql/query, listens for write notifications, and re-fires the query when any referenced table changes.
Vanilla JS example#
<!DOCTYPE html>
<html><head><title>Posts</title></head>
<body>
<ul id="list"></ul>
<script>
rush.sql.subscribe(
'SELECT id, title, date FROM posts ORDER BY date DESC',
{},
(rows) => {
document.getElementById('list').innerHTML = rows
.map(r => `<li>${r.date} - ${r.title}</li>`)
.join('');
}
);
</script>
</body></html>React example#
The iframe can be a full SPA. Wrap sql.subscribe in a hook for a TanStack-Query-shaped API:
function useSqlQuery(sql, params = {}) {
const [state, setState] = useState({ loading: true, data: [] });
useEffect(() => {
const sub = window.rush.sql.subscribe(sql, params, (data) => {
setState({ loading: false, data });
});
return () => sub.unsubscribe();
}, [sql, JSON.stringify(params)]);
return state;
}
function Dashboard() {
const { data: posts, loading } = useSqlQuery(
'SELECT * FROM posts ORDER BY date'
);
if (loading) return <Spinner/>;
return <Calendar posts={posts}/>;
}Each useSqlQuery is an independent subscription - only re-renders when its referenced tables change. Multiple subscriptions on the same page is the common case.
What happens when the agent writes#
LLM tool call: sqlite_insert(table=posts, ...)
|
v
fileAgentSQLStore.Insert (schema-validated)
|
v
sqlite3 update_hook fires -> TableChange{agent, table, op, rowid, ts}
|
v
BaseAgent goroutine emits ui_app_table_changed NDJSON event
|
v
rush/app forwards to MCPHTMLRenderer
|
v
postMessage 'ui/notifications/sql-table-changed' -> iframe
|
v
rush.sql.subscribe listener re-runs query -> component re-rendersEnd-to-end latency: typically < 50 ms from write to re-render on a local agent. No polling, no manual refresh, no LLM round-trip.
Security model#
ui_apps render through the same sandboxed iframe path as MCP Apps, so every guardrail there applies:
- Sandbox:
allow-scripts allow-formsonly.allow-same-originis never granted, so the iframe cannot reach the parent DOM or read host cookies. - CSP:
connect-src 'none'by default. Outbound network requires explicitcsp.connect_domainsopt-in. - SQL surface:
rush.sql.queryandrush.sql.subscriberoute through the host's read-onlyui/sql/querypath, which reads the agent's SQLite memory throughagent-memory:query. Writes can only happen via LLM-mediated tool calls - the iframe cannot write or alter schema. - Memory scoping: subscriptions only see writes for the agent's own
memory.db. Cross-agent reads are blocked at the harness layer. - Tool whitelist:
rush.tool.callcan only invoke tools listed intools_allowed.
The full threat model is documented in rush/cli/docs/08-mcp-apps-protocol.md (“Security Model” section). Same iframe sandbox that ships third-party MCP Apps today.
How the HTML ships#
The HTML file lives inside the encrypted .rush container alongside agent.yaml. rush build packages it; rush install unpacks it. The harness reads it at agent-build time and serves it through the synthesized MCP App render envelope - no static-asset hosting, no separate deploy step.
For very small widgets you can inline the HTML directly in YAML via inline: |. Trade-off: YAML readability degrades quickly past ~50 lines.
v1 limitations#
rush.tool.callonly reaches real MCP-server tools today. Calling the agent's own tools (e.g., posting to Twitter from an iframe button) isn't routed in v1 - userush.ui.sendMessage('please post...')to ask the LLM instead.- One iframe per
ui_apps:entry, instantiated when the LLM callsui_app_<name>as a tool. Future v2: auto-instantiate on agent start. sql.subscribeextracts referenced tables via regex. Complex queries (CTEs, subqueries, dynamic tables) may not get the full set. Prefer simpler table references or a directsql.queryfor one-shot reads.
Reference test agent#
A complete working example ships in the rush-tester subagent:
# Source
agents/rush-tester/ui-apps-tester.yaml
agents/rush-tester/testdata/ui-app-cadence.html
# Run it
rush run ./agents/rush-tester --sub ui-apps-tester \
--prompt "Open the dashboard, insert 3 posts, confirm live update"Run it with the Rush app open in dev mode (bun run dev from rush/app) to see the iframe's row count increment as the agent inserts posts.