App React UI

Apps can ship React panels that appear in Avi's project chat sidebar. Use panels when an app needs a focused UI for browsing, editing, or reviewing the same data its tools use.

Panels are not loaded into Avi's main React tree. Avi builds each panel as a browser bundle, serves it inside a sandboxed iframe, and exposes a small bridge for data access, tool calls, theme tokens, and notifications.

When to use a panel

Add a React panel when users need to:

  • inspect app-owned records, queues, dashboards, or sync status;
  • edit structured data with controls that are awkward through chat alone;
  • run an app-specific workflow repeatedly;
  • share state between the UI and the agent's tools.

Keep the agent-facing behavior in tools. The panel is the human interface; tools are still what the agent calls during chat and tasks.

Project shape

avi init creates a starter app with a panel:

text
my-app/
  app.ts
  ui/
    Dashboard.tsx
  package.json
  tsconfig.json

The panel entry lives under ui/ by convention, but any relative path inside the app directory is allowed.

Declare panels in app.ts

Declare UI panels on the same defineApp call as your tools:

ts
import { defineApp, type AppToolContextForCapabilities } from "@avihq/apps-sdk";

const capabilities = ["data:read", "data:write"] as const;

const data = {
  customers: {
    type: "collection",
    fields: {
      name: { type: "string", required: true },
      status: { type: "enum", enum: ["lead", "active", "at-risk", "churned"] },
    },
    fullText: ["name"],
  },
} as const;

export default defineApp({
  name: "customers",
  description: "Customer records with chat tools and a sidebar UI.",
  capabilities,
  data,
  ui: {
    panels: {
      dashboard: {
        title: "Customers",
        icon: "users",
        entry: "./ui/CustomersPanel.tsx",
      },
    },
  },
  tools: {
    "list-customers": {
      description: "List customer records.",
      inputSchema: {
        type: "object",
        properties: {},
        additionalProperties: false,
      },
      async handler(_input, context: AppToolContextForCapabilities<typeof capabilities, typeof data>) {
        return {
          customers: await context.data.customers.list(),
        };
      },
    },
  },
});

Panel ids are the keys under ui.panels. They must be kebab-case, just like app and tool names.

Panel definition

Each panel has:

FieldRequiredNotes
titleYesShown in the sidebar frame header. Maximum 60 characters.
iconNoA supported sidebar icon name, a relative image path, an HTTPS image URL, or a small data:image/... URI. Defaults to panel-right.
entryYesRelative .tsx, .ts, .jsx, or .js path inside the app directory.
subscribeEventsNoUp to 5 event-type patterns (exact types or a prefix.* glob, e.g. "slack.message.*"). See Live updates.

Built-in icons:

text
bar-chart, calendar, chart, clipboard-list, database, folder, gauge,
grid, inbox, layout-dashboard, list, mail, notebook, panel-right,
settings, table, users

Custom icons are rendered as 16 px sidebar images. Use a relative SVG, PNG, JPEG, GIF, or WebP path such as ./ui/icon.svg to have the CLI bundle it into the deployed manifest, or use an HTTPS image URL for a remotely hosted icon.

An app can define up to five panels.

Write the React component

Panel code is normal React. Export a default component from the entry file. UI components come from Avi's component library, @avihq/ui — import them from its /panel entry so the library's stylesheet loads inside the sandboxed iframe automatically — and bridge hooks come from @avihq/apps-sdk/react:

tsx
import { useEffect, useState } from "react";
import {
  Button,
  EmptyState,
  Input,
  PanelHeader,
  PanelShell,
  Spinner,
  Table,
  Toolbar,
} from "@avihq/ui/panel";
import { useAvi } from "@avihq/apps-sdk/react";

interface Customer {
  name: string;
  status?: string;
}

export default function CustomersPanel() {
  const avi = useAvi();
  const [customers, setCustomers] = useState<Array<{ id: string; record: Customer }>>([]);
  const [loading, setLoading] = useState(true);
  const [name, setName] = useState("");

  async function load() {
    setLoading(true);
    // `customers` is the named collection store declared in app.ts.
    const { records } = await avi.data.customers.list();
    setCustomers(records);
    setLoading(false);
  }

  async function save() {
    await avi.data.customers.create({ name, status: "lead" });
    await avi.toast.show({ type: "success", message: "Customer saved" });
    setName("");
    void load();
  }

  useEffect(() => {
    void load();
  }, []);

  return (
    <PanelShell>
      <PanelHeader title="Customers" description="Records owned by this app." />

      <Toolbar>
        <Input value={name} onChange={(event) => setName(event.currentTarget.value)} />
        <Button onClick={save} disabled={!name.trim()}>Save</Button>
      </Toolbar>

      {loading ? (
        <EmptyState title="Loading" icon={<Spinner />} />
      ) : customers.length === 0 ? (
        <EmptyState title="No customers" />
      ) : (
        <Table>
          <tbody>
            {customers.map((customer) => (
              <tr key={customer.id}>
                <td>{customer.record.name}</td>
                <td>{customer.record.status ?? "—"}</td>
              </tr>
            ))}
          </tbody>
        </Table>
      )}
    </PanelShell>
  );
}

The CLI wraps your component with React's createRoot, so the entry file should export the component rather than calling createRoot itself.

The Avi component library (@avihq/ui)

@avihq/ui is the same component library Avi's own dashboard and built-in apps use, so panels built with it look like an official part of the product. Every component is styled through Avi's design tokens and follows the host theme (dark/light) automatically. It ships with the starter project; import from @avihq/ui/panel in panel code (the bare package root is for hosts with their own CSS pipeline and won't inject styles into the iframe).

GroupComponents
Form controlsButton, Input, Textarea, Select, Checkbox, Switch, RadioGroup, Slider, TagInput, DatePicker, DateRangePicker, ToggleGroup
FeedbackAlert, Badge, Menu, Modal, Spinner
NavigationTabs (with Tabs.Tab and Tabs.Panel)
Data displayCard, Table (+ header/body/row/cell subcomponents), Heading, Text, Divider, CodeBlock (syntax-highlighted)
Panel chromePanelShell, PanelBody, PanelHeader, Toolbar, EmptyState

Panel layout rule — never pad the panel's outer element. PanelHeader, Toolbar, Tabs, and tables draw edge-to-edge borders and carry their own internal padding; horizontal padding on an ancestor cuts those borders short of the panel edges. Use PanelShell as the unpadded full-height root with full-bleed rows directly inside it, and keep padding in PanelBody (its flush prop drops the padding for full-bleed tables/lists).

Heavier building blocks ship as separate entry points so your panel only bundles what it imports:

EntryWhat you get
@avihq/ui/chartsThemed chart components on recharts — LineChart, BarChart, AreaChart, PieChart, ScatterChart, RadarChart, ComposedChart
@avihq/ui/data-tableDataTable — virtualized table with sorting, filtering, and pagination for large datasets; pass flush when it sits full-bleed in a panel so its container border doesn't double up
@avihq/ui/editorEditor — Avi's rich markdown editor (toolbar, GFM, markdown strings in/out)

Charts are heavy (~480 KB minified) — keep the 1 MB panel bundle limit in mind.

Using the library is encouraged, not required — regular React with your own styling works too (see Theme and styling for the token variables to use).

The older UI primitives exported from @avihq/apps-sdk/react (Button, Input, Badge, …) still render for already-deployed apps, but new panels should use @avihq/ui/panel.

SDK React exports

Hooks:

ExportPurpose
useAvi()Returns the full panel bridge.
useTheme()Returns { mode, tokens } for the current Avi theme.
useAviView(defaultView?)Persistent panel view — see Persisting the panel's view.
useAppData()Shortcut for useAvi().data — the named data stores.
useAppRecords()Deprecated alias of useAppData(). Record collections are now declared data stores; reach them via useAppData().<storeName>.
useProjectUpdates()Shortcut for useAvi().updates.
useProjectTasks()Shortcut for useAvi().tasks.
useProjectContacts()Shortcut for useAvi().contacts.
useAppTool()Shortcut for useAvi().apps.invoke.

Bridge API

Panels cannot call Avi APIs directly. Use useAvi() to make bridge calls through the parent app:

ts
const avi = useAvi();

Available bridge methods:

Data goes through the app's named stores, keyed by the names declared in defineApp({ data }). avi.data.<store> is a KV-store handle when the store is type: "kv" and a record-collection handle when it is type: "collection" (the bridge routes the call from the store→type map injected into the panel frame). avi.records is removed.

APICapabilityWhat it does
avi.data.<kvStore>.get(key)data:readRead a value from a declared KV store.
avi.data.<kvStore>.has(key)data:readCheck a key in a declared KV store.
avi.data.<kvStore>.listKeys(options)data:readList keys in a declared KV store.
avi.data.<kvStore>.set(key, value)data:writeWrite a value to a declared KV store.
avi.data.<kvStore>.delete(key)data:writeDelete a key from a declared KV store.
avi.data.<collection>.get/list/searchdata:readRead records from a declared collection store and run full-text/vector search.
avi.data.<collection>.create/update/deletedata:writeWrite records in a declared collection store.
avi.updates.list(options)updates:readRead project timeline updates. (Updates are published from the app's handler via context.updates.publish, not from panels.)
avi.tasks.get/list/searchtasks:readRead and search project tasks.
avi.tasks.create/update/deletetasks:writeCreate, edit, or delete project tasks.
avi.contacts.get/listcontacts:readRead contacts in the invoking project's contact pool.
avi.contacts.create/update/deletecontacts:writeCreate, edit, or delete contacts in the invoking project's contact pool.
avi.notes.get/listnotes:readRead notes in the invoking project.
avi.notes.create/update/deletenotes:writeCreate, edit, or delete notes in the invoking project.
avi.files.get(key)files:readRead a project/app file as string data.
avi.files.put(key, blob)files:writeWrite a project/app file.
avi.projectFiles.list/get/searchproject:files:readRead and search the project's shared file pool (the Files panel).
avi.projectFiles.put/delete/move/updateMetadataproject:files:writeCreate, edit, move, or delete files in the project's shared file pool.
avi.apps.invoke(tool, input)apps:invokeInvoke another enabled app tool.
avi.user.current()NoneReturn the current user id, or null.
avi.toast.show(input)NoneShow an Avi toast in the parent app.

The bridge enforces the same approved capabilities as tool handlers. If a panel calls avi.data.<store>.set but the app was not approved for data:write, the call fails.

Bridge values must be JSON-compatible except file blobs, which use:

ts
{
  data: string;
  contentType?: string;
}

Shared state with tools

Panels and tools read and write the same named data stores. The panel reaches a store through avi.data.<storeName> and a tool reaches the same store through context.data.<storeName> — for app-like data, prefer a type: "collection" store so both sides share CRUD, filters, full-text search, and vector search.

For example, a panel can write to the declared customers collection store:

ts
await avi.data.customers.create({
  name: "Northstar Labs",
  status: "active",
  notes: "Expansion candidate",
});

Then a tool in the same app can read it:

ts
const customers = await context.data.customers.search({
  text: "expansion",
  vector: "accounts likely to expand",
});

Each store's scope (declared per store in defineApp({ data })) decides isolation: a "project" store is isolated by project and app; an "org" store is isolated by org and app and shared across the org's enabled projects.

Invoking tools from a panel

Use avi.apps.invoke only when the panel needs behavior already exposed as a tool:

ts
const result = await avi.apps.invoke("customers_list-customers", {
  status: "active",
});

Tool names use Avi's runtime format:

text
<app-name>_<tool-name>

The target app must be enabled for the current project, and the current app must have the apps:invoke capability approved.

Tools default to callable from both the agent and app UI. To keep a panel helper out of the agent's tool list, mark it as UI-only in app.ts:

ts
tools: {
  "refresh-panel-data": {
    description: "Refresh dashboard data for the React panel.",
    inputSchema: { type: "object", additionalProperties: false },
    callableFrom: "ui",
    async handler() {
      return { ok: true };
    },
  },
}

Do not use tool invocation as a substitute for simple data reads and writes. Prefer avi.data for panel-local CRUD and keep tool invocation for reusable business logic or cross-app composition.

Theme and styling

Avi sends theme tokens into the iframe and keeps them updated when the user changes theme.

The @avihq/ui components use Avi's design tokens automatically. For custom styling, use the same CSS variables — they are plain color values (use them directly, never wrapped in hsl()):

css
var(--color-background-primary)   /* main surface */
var(--color-background-secondary) /* subtle surfaces, hovers */
var(--color-text-primary)         /* primary text */
var(--color-text-secondary)       /* muted text */
var(--color-border-primary)       /* default borders */
var(--color-text-danger)          /* error text (also -success, -warning, -info) */
var(--border-radius-md)
var(--font-mono)
tsx
<div style={{ borderBottom: "1px solid var(--color-border-primary)" }}>
  <span style={{ color: "var(--color-text-secondary)" }}>Synced just now</span>
</div>

Use useTheme() if the component needs to branch on light or dark mode:

tsx
import { useTheme } from "@avihq/apps-sdk/react";

const theme = useTheme();
const isDark = theme.mode === "dark";

Panels should be compact and task-focused. They live in the project sidebar, so dense tables, filters, editors, and status summaries usually work better than landing-page layouts.

Persisting the panel's view

Avi remembers which panel each project had open. Your panel's internal state — the active tab, the open record — is yours to describe: report it as a view, a single opaque string, and Avi mirrors it into the page URL. When the user switches projects and comes back, reloads, or shares the link, your panel reopens exactly where they left it.

useAviView is a drop-in replacement for useState on whatever string names your navigation state:

tsx
import { useAviView } from "@avihq/apps-sdk/react";

// A tab bar:
const [view, setView] = useAviView("overview");

// Or an open record:
const [selectedId, setSelectedId] = useAviView();

Rules of thumb:

  • The string is opaque to Avi — encode whatever you want (tab name, record id, tab/record path). It is capped at 512 characters.
  • Always validate restored values: a remembered view can be stale (a deleted record, a renamed tab). Fall back to your default instead of rendering a broken state.
  • Use it for navigation state, not ephemeral UI state (search text, sort order, scroll position).

Doing nothing is also fine — a panel that never calls useAviView just opens at its default view every time.

Live updates

Panels are sandboxed (connect-src 'none'), so they cannot open their own network connections. Live behavior is host-brokered, and there are two kinds of it: the app's own live events (its private backend→panel channel) and project activity (declared).

Live events — the app's own channel

Every app has a private live-event stream from its backend to its open panels: arbitrary named events that trigger panel refreshes — or any panel reaction — for anything, not just data changes. Two publishers feed it:

  • Automatic: every data-store write publishes "data.changed" (with the store name), no matter who wrote — a tool running in chat, another open panel, a background run. On by default; a store opts out with live: false in its definition.
  • Your backend, for anything else: context.live.publish(name) from any tool or the background handler — an external sync finished, a webhook landed, a long job progressed:
ts
await context.live.publish("sync.finished");
await context.live.publish("import.progress", { scope: "org" });

Names are yours to invent (lowercase dotted segments, max 64 chars; the data. namespace is reserved). scope: "project" (default) reaches the invoking project's panels; "org" reaches the app's panels across the org's projects. Bursts coalesce (~250ms per name), and no capability is needed — live events can only ever reach your own app's panels.

On the panel side, useLiveData is the one-liner — the loader runs on mount and re-runs (debounced) on every live event:

tsx
import { useLiveData, useAvi } from "@avihq/apps-sdk/react";

function CustomersPanel() {
  const [rows, setRows] = useState([]);
  const avi = useAvi();

  const { refresh, isRefreshing } = useLiveData(async () => {
    const { records } = await avi.data.customers.list();
    setRows(records);
  }, {
    stores: ["customers"],     // optional: which data changes count
    events: ["sync.finished"], // optional: which published events count
  });
}

useLiveEvents is the raw stream, for reactions that aren't refetches:

tsx
useLiveEvents(({ name, store }) => {
  if (name === "export.ready") setBanner("Your export is ready");
});

Events carry only their name — never payloads; the loader refetches through the capability-checked bridge, exactly like a manual refresh. refresh() triggers the loader manually; isRefreshing is true while it runs; the loader also re-runs after a WebSocket reconnect. One app's live events never reach another app's panels.

Rail dots are opt-in. While the panel is closed, only a publish that explicitly asks for attention lights the tile's dot:

ts
await context.live.publish("inbox.updated", { notify: true });

Silent publishes and the automatic data.changed events still refresh open panels but never dot — reserve notify for moments a human should notice (an export finished, new inbound items arrived), not routine data churn. Manifest subscribeEvents matches keep dotting as before.

Do not add app.live.event (the platform's internal wire type) to subscribeEvents — your app's live events are delivered automatically, and deploys that declare it are rejected.

Project activity — declared

To react to project activity (Slack messages, task changes, vendor events), declare event patterns on the panel:

ts
panels: {
  team: {
    title: "Team",
    entry: "./ui/TeamPanel.tsx",
    subscribeEvents: ["slack.message.*"],
  },
},

Two things happen for a declared pattern:

  • In-panel nudges. While the panel is open, the host forwards each matching project event into the iframe as a lean nudge — the event type only, never the payload. Treat a nudge as "something changed, refetch through your tools." The host also forwards "*" after a WebSocket reconnect: refetch everything, since events may have been missed while offline.
  • Rail unread dot. While the panel is closed, a matching event puts a small dot on the panel's rail tile. Opening the panel clears it.

Subscribe with useAviEvents from @avihq/apps-sdk/react (latest-handler semantics — no memoization needed; never fires outside a panel host):

tsx
import { useAviEvents } from "@avihq/apps-sdk/react";

useAviEvents(({ eventType }) => {
  // eventType: "slack.message.received", or "*" after a reconnect.
  refetch();
});

Rules:

  • Up to 5 patterns per panel; each is an exact event type or a prefix.* glob, max 100 characters.
  • Nudges only fire for events in the project the panel is mounted in.
  • Debounce your refetches (bursts of events arrive together) and never render from a nudge — always refetch through a tool or data store.

Build and deploy

avi build bundles app.ts into dist/app.mjs for the Lambda runtime and validates the panel manifest shape: ids, titles, icons, and entry paths.

avi deploy rebuilds the app, bundles each panel for the browser sandbox, uploads the Lambda deployment package, and uploads the panel bundles. Avi serves the latest panel bundle for the deployed app revision.

React ships inside the panel bundle, so declare both react and react-dom at the same version in your app's package.json. The build always bundles a single, version-matched pair — if your install ends up with mismatched copies, avi deploy fails with an error telling you which versions to install.

Run:

bash
npm run typecheck
avi build
avi deploy

After deploy, enable the app in a project. If the app is owned by the current org and defines panels, the project chat sidebar icon rail shows the panel icons.

Security model

Panels run inside an iframe with a restrictive sandbox and content security policy:

  • the iframe allows scripts and forms only;
  • panel code cannot access the parent DOM;
  • panel code cannot read Avi cookies, auth tokens, or local storage;
  • network access from inside the iframe is blocked by CSP;
  • bridge calls are handled by Avi and checked server-side.

This means panel code should treat useAvi() as its only Avi integration point.

Limits and validation

Current limits:

LimitValue
Panels per app5
Bundle size per panel1 MB
Panel title length60 characters
Entry extensions.tsx, .ts, .jsx, .js
VisibilityOwn-org apps only

Build-time validation rejects:

  • absolute panel entry paths;
  • paths that leave the app directory;
  • unsupported file extensions;
  • invalid icon values, such as empty strings, very large values, absolute paths, paths outside the app directory, unsupported URL schemes, or non-image data URIs;
  • Node built-in imports such as fs, path, or node:crypto;
  • mismatched react / react-dom versions in the app's dependencies;
  • bundles over the size limit.

Troubleshooting

The panel icon does not appear

Confirm the app is enabled for the project, belongs to the current org, deployed successfully, and has ui.panels in app.ts.

The build fails with a Node built-in import error

Panel bundles target the browser. Move Node-only work into a tool handler or a shared API called by a tool, then let the panel use bridge APIs.

A bridge call fails with a capability error

Add the required capability to defineApp({ capabilities }), redeploy, and approve the capability when enabling the app.

The panel renders but does not share data with tools

Check that both sides use the same declared store. avi.data.<storeName> in the panel corresponds to context.data.<storeName> in a tool — same store name, same per-store scope.

The iframe shows a load error

Run avi build locally first to catch manifest issues, then run avi deploy to catch browser-only bundle issues such as Node built-in imports and bundle size.

The panel opens but stays blank

A blank panel usually means the bundled React crashed on startup. Make sure react and react-dom are both declared in the app's package.json at the same version, reinstall, and redeploy.

Example

The repo includes a complete example at packages/apps/customers:

  • app.ts declares a customers app, a dashboard panel, and CRUD tools.
  • ui/CustomersPanel.tsx renders a sidebar UI with search, editing, persistence, and toasts.
  • Both the panel and tools read and write the same customers project-scoped collection store (avi.data.customers / context.data.customers).

Deploy it with:

bash
cd packages/apps/customers
npm install
avi deploy