Docs

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 boundaryFree-form HTML/CSS/JS authored by you
Per-component action handlers wired to toolsLive data binding to the agent's SQLite memory
You want the registry to enforce a contractYou 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:

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

FieldTypePurpose
titlestringShown in the iframe chrome and as the tool description
htmlstring (path)Path to HTML file, relative to agent dir. Mutually exclusive with inline.
inlinestring (HTML)Inline HTML for tiny widgets. Mutually exclusive with html.
tools_allowedstring[]Whitelist for window.rush.tool.call. Defaults to sqlite-only.
permissionsobjectIframe sandbox capabilities: camera, microphone, geolocation. allow-same-origin is never granted.
cspobjectContent-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:

window.rush - TypeScript shape
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#

apps/cadence.html
<!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:

apps/dashboard.tsx
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-renders

End-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-forms only. allow-same-origin is never granted, so the iframe cannot reach the parent DOM or read host cookies.
  • CSP: connect-src 'none' by default. Outbound network requires explicit csp.connect_domains opt-in.
  • SQL surface: rush.sql.query and rush.sql.subscribe route through the host's read-only ui/sql/query path, which reads the agent's SQLite memory through agent-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.call can only invoke tools listed in tools_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.call only 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 - use rush.ui.sendMessage('please post...') to ask the LLM instead.
  • One iframe per ui_apps: entry, instantiated when the LLM calls ui_app_<name> as a tool. Future v2: auto-instantiate on agent start.
  • sql.subscribe extracts referenced tables via regex. Complex queries (CTEs, subqueries, dynamic tables) may not get the full set. Prefer simpler table references or a direct sql.query for 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.

Documentation | Prix | Prix