App React UI

Apps can ship React panels that open from the rail in Avi (the dock on a phone). 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",
  title: "Customers",
  summary: "Customer records with chat tools and a panel.",
  icon: "./icon.svg",
  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,
      },
      outputSchema: {
        type: "object",
        properties: { customers: { type: "array", items: { type: "object", additionalProperties: true } } },
        required: ["customers"],
      },
      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
titleYesThe panel's name, shown in its frame header. Maximum 60 characters.
iconNoA built-in 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 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. Prefer one, with extra surfaces as tabs inside it.

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 { useState } from "react";
import { AppShell, Button, EmptyState, Input, Spinner, Toolbar } from "@avihq/ui/panel";
import { DataTable } from "@avihq/ui/data-table";
import { useAvi, useLiveData } 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("");

  // Loads on mount and re-runs automatically whenever the store changes —
  // `customers` is the named collection store declared in app.ts.
  useLiveData(async () => {
    const { records } = await avi.data.customers.list();
    setCustomers(records);
    setLoading(false);
  }, { stores: ["customers"] });

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

  return (
    <AppShell
      breadcrumbs={{ items: [{ label: "Customers" }] }}
      toolbar={
        <Toolbar>
          <Input value={name} onChange={(event) => setName(event.currentTarget.value)} />
          <Button onClick={save} disabled={!name.trim()}>Save</Button>
        </Toolbar>
      }
      padded={false}
    >
      {loading ? (
        <EmptyState title="Loading" icon={<Spinner />} />
      ) : customers.length === 0 ? (
        <EmptyState title="No customers" />
      ) : (
        <DataTable
          columns={[
            { accessorKey: "record.name", header: "Name" },
            { accessorKey: "record.status", header: "Status" },
          ]}
          data={customers}
          flush
          getRowId={(row) => row.id}
        />
      )}
    </AppShell>
  );
}

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; this page assumes version 0.3 or later — if your app's package.json lists an older @avihq/ui, run npm install @avihq/ui@^0.3.0. 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
LayoutAppShell (the standard panel container), SplitView (adaptive master-detail), ActionBar (pinned actions), Band (the standard bar — use it for every title or option bar of your own), Toolbar, EmptyState
Form controlsButton, Input, Textarea, Select, Checkbox, Switch, RadioGroup, Slider, TagInput, DatePicker, DateRangePicker, ToggleGroup
FeedbackAlert, Badge, DraftBadge, Menu, Modal (with ModalHeader, ModalBody, ModalActions), ConfirmDialog, Tooltip, InfoTip, Spinner
NavigationTabs (with Tabs.Tab and Tabs.Panel), Breadcrumbs
Data displayCard, Table (with TableHeader, TableBody, TableRow, TableHead, TableCell), Heading, Text, Divider, CodeBlock (syntax-highlighted), Markdown (read-only GFM renderer)
RecordsRecordPage, RecordHeading, DetailRow, LoadingPane, DeleteArmedButton, ReplyContext, FilterControl, useSortVoice, replaceListingWindow, and the useLiveRecord hook — see Records: lists, record views, and editing
HooksuseSurface, useVoiceTargets, useVoiceScreen

InfoTip needs a release of @avihq/ui later than 0.3.0.

AppShell — the standard panel container

Structure every panel as an AppShell. Chrome rows — breadcrumbs, tabs, toolbar — are slots, so they render full-bleed at the top in the right order, and body padding can never push them off the panel edges. The body below is the panel's single scroller, padded by default:

tsx
<AppShell
  breadcrumbs={{ items: [{ label: "Deals", onClick: home }, { label: deal.title }] }}
  tabs={{ tabs: [{ value: "details", label: "Details" }, { value: "notes", label: "Notes" }], value: tab, onChange: setTab }}
>
  {tab === "details" ? <Details /> : <Notes />}
</AppShell>

Pass padded={false} for full-bleed tables and lists, scrollable={false} when the body manages its own panes (SplitView). AppShell also scopes Modal dialogs to the panel automatically — no container wiring — and wraps the body in an error boundary, so a render crash shows a themed fallback instead of a blank panel.

Tab-row actions go through the tabs slot's action and menu fields rather than custom elements in the row. On mobile the row shows navigation plus at most one kebab (the action folds in as its first item). On the sidebar the action shows inline while the row is at least 480px wide — the standard row breakpoint, shared with Breadcrumbs' collapseBelow — and folds into the kebab below that; the measurement is container width, so a row inside a SplitView pane folds by its pane. On fullscreen the action is always inline, compact, never touching the row borders.

Breakpoints within a surface. The shell and each SplitView pane are CSS size containers, so your own markup can step by container width with Tailwind container-query variants — @md:grid-cols-2, @min-[480px]:flex-row. Surfaces decide ergonomics (touch vs pointer); container breakpoints decide density within a surface.

SplitView — adaptive master-detail

For list-plus-record panels, render a SplitView inside the shell: a list pane, a detail pane, and detailOpen driven by your selection state (usually a useAviView record id). It adapts to the surface automatically — side-by-side panes on fullscreen, a state-preserving drill-in on the sidebar and mobile where the open record covers the list (the list keeps its scroll position; backing out is instant). Give the detail a Breadcrumbs whose root part is the back affordance, and supply an EmptyState for fullscreen's nothing-selected case.

ActionBar — pinned actions

Record-level actions (Reply on a thread, Save on an editor, Delete) go in an ActionBar — AppShell takes it as an actionBar slot, and inside a SplitView detail pane you render it after the pane's scroller. The bar is always the LAST row of its pane, on every surface, and casts the band shadow upward over the content scrolling past it (a hand-placed bar at the top of a pane can flip that with placement="top"). Structured props (action, secondaryAction, utilities with icons) render by surface: compact icons-left/actions-right on sidebar and fullscreen; on mobile the bar grows to touch height and the primary action stretches across the remaining width. A children escape hatch keeps the chrome for flows the config can't express.

Surfaces — adapting to where the panel is mounted

The host stamps data-avi-surface on the panel document: sidebar (desktop side panel — dense, pointer-driven, the default), mobile (the native app or mobile web — touch ergonomics), or fullscreen. The library's components adapt on their own (touch-size tabs on mobile, side-by-side SplitView on fullscreen). For your own markup, use the mobile:/fullscreen: Tailwind variants (text-sm mobile:text-[15px]), and reach for the useSurface() hook only when the DOM shape itself must change. Never branch layout on viewport width — a 400px desktop sidebar and a phone are the same width but need different ergonomics.

PanelShell, PanelBody, and PanelHeader remain for panels that predate AppShell; new panels should not use them.

Entry points

Heavier building blocks ship as separate entry points so your panel only bundles what it imports. They don't load the library's stylesheet themselves — it comes from @avihq/ui/panel, which every panel already imports for AppShell:

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

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

Records: lists, record views, and editing

Most panels are some form of "browse records, open one, act on it." The library ships that whole pattern as components, so you compose instead of rebuilding it — and everything you compose from them is automatically voice-operable.

RecordBrowser — browse records, open one

RecordBrowser (from @avihq/ui/data-table) is the complete list-plus-record surface: a SplitView that shows panes side by side on fullscreen and drills in on the sidebar and mobile, a DataTable list with infinite scroll (pass virtualized for long lists), and a built-in record header — breadcrumbs whose root closes the record, an optional actions slot, and a close button. Selection is controlled: you pass openId and onOpenChange (usually backed by useAviView), and the browser renders whatever you say is open.

tsx
import { RecordBrowser } from "@avihq/ui/data-table";

<RecordBrowser
  title="Invoices"
  records={invoices}
  columns={[
    { accessorKey: "vendor", header: "Vendor", narrow: "title" },
    { accessorKey: "amount", header: "Amount", narrow: "meta" },
  ]}
  getRecordId={(invoice) => invoice.id}
  openId={openId}
  onOpenChange={setOpenId}
  detail={openId ? <InvoiceDetail id={openId} /> : null}
  detailTitle={openInvoice?.vendor}
  voice={{
    noun: "invoice",
    recordLabel: (invoice) => invoice.vendor,
    recordPhrases: (invoice) => [invoice.number],
  }}
/>

What you get for free: narrow panes adapt by column priority (columns that don't fit are dropped, never squished) or by narrow slots ("title", "meta", "line" — the list becomes one composed row per record that STACKS vertically: the title on its own line, the meta cells on one secondary line below it in column order, each line column full-width under that; the object form narrow: { slot, cell } swaps in a narrow-specific cell, e.g. a title that wraps to two lines); loading, emptyTitle/emptyDescription, and onEndReached cover first load, empty, and infinite scroll; listHeader is the standard toolbar band for filters and actions; and the voice config makes every row spoken-addressable ("open the third invoice"). For lists a flat table can't express (day groups, sections), pass listContent and render your own body — the browser still owns the panes, the record header, and selection.

Use RecordBrowser for anything list-shaped with an open-record state. Reach for raw SplitView + DataTable only when the pattern genuinely doesn't fit.

FilterControl — the standard list filters

FilterControl renders the one compact Filter button with a dropdown over declared filter groups — single-select (with an optional "All" row via emptyLabel), multi-select, and date ranges — plus an active-count badge and a clear-all row. Declare the groups as data and handle one callback:

tsx
import { FilterControl } from "@avihq/ui/panel";

<FilterControl
  groups={[{
    id: "status", label: "Status", kind: "select", emptyLabel: "All",
    options: [{ id: "open", label: "Open" }, { id: "done", label: "Done" }],
    value: status,
  }]}
  onChange={(groupId, value) => setStatus(value as string | null)}
/>

Put it in a list's toolbar (RecordBrowser's listHeader slot is the standard spot). Use one FilterControl per view — it also carries the view's complete spoken filter surface ("only open", "clear the filters", "filter from June 1 to June 15") with no extra code.

A multi-select filter group adds a spoken pick to its selection by default. Set voicePicksOne: true on a group whose resting selection isn't empty, such as a task list that shows two unfinished statuses by default. Then "only complete" selects exactly that option instead of widening the list. Clicking still toggles each checkbox.

Sorting — the header row is the control

Lists sort by clicking a column header (sortable); there is no separate Sort button. Give the list a default (newest activity first is what Avi's own apps use) and keep the state controlled so header clicks, the persisted preference, and spoken sorts are one state:

tsx
import { RecordBrowser, type SortState } from "@avihq/ui/data-table";

const DEFAULT: SortState = { id: "updated", desc: true };
const [sort, setSort] = useState<SortState>(DEFAULT);

<RecordBrowser
  sortable
  sorting={[sort]}
  onSortingChange={(next) => setSort(next[0] ?? DEFAULT)}
  voiceSorts={[
    { id: "updated", label: "updated", aliases: ["date"], flavor: "date" },
    { id: "state", label: "state", directionLabels: ["Open first", "Closed first"] },
    { id: "title", label: "title", aliases: ["name"], flavor: "text" },
  ]}
  …
/>

Each sortable column needs an accessorFn (or accessorKey). voiceSorts on RecordBrowser speaks the sorts ("sort by updated", "newest first", "sort by name", "a to z", "closed first", and the aliases you declare) and drives the same controlled state, which is what keeps sorting alive in a narrow pane: the stacked rows have no header cells, so RecordBrowser orders the records itself from the columns' accessors under that state. Persist the value like any list preference (a KV key per list). Keep aliases to two per sort: every phrase past the per-target cap becomes a published shard, and shards take manifest room from rows.

Fields speak by default

Every form field in the library answers to speech with no wiring: Input, Textarea, Select, DatePicker, Checkbox, and Switch. A field is spoken of by the name it already shows: its label, else its aria-label, else a placeholder you set. So <Input label="Title" … /> answers "the title is Q3 plan", "set the title to …", "type in the title …", and "clear the title". A field with no name at all can't be spoken to, so give an unlabeled field an aria-label.

  • voice={{ id, label }} names the field yourself: a stable id for the agent (field:<id>) and the words people use ("the comment").
  • voice={false} opts a field out.
  • Secrets never speak: passwords, card numbers, one-time codes, and hidden or file inputs register nothing, and neither do disabled or read-only fields. Only the field's name is published, never its value.
  • "the <name> is …" and "set the <name> to …" replace the value; "type in the <name> …" adds to it.
  • Checkboxes and switches answer "turn on <name>", "turn off <name>", "check …", "uncheck …", and "toggle <name>", through your own onChange.

Inside a dialog, the focused field (or the dialog's only field) also takes speech that matches nothing else, so a person can just say the name a dialog asks for: "Acme engagement letter". It fills the field and never submits. Questions and anything addressed to Avi ("Avi, …") still go to the agent. Give a dialog its own extra phrases with <Modal voiceTargets={[…]}>: a useVoiceTargets call in the component that renders the Modal sits outside the dialog and is silenced while it is open.

Dictation into fields — voice on Textarea and Input

A text field that declares voice={{ id, label }} (or is named by its label, as above) takes speech: "type in the comment looks good to me" appends to it (the field's own label always names it; while the field is FOCUSED the bare "type …", "write …", "dictate …", "enter …" reach it too, so a modal's single field takes plain dictation), and "clear the comment" empties it. The agent types into the same field through the same door, addressing it as field:<id> (or by its label) — so when your view opens a composer, publish a useVoiceScreen note (300 characters at most) that leads with typing into field:<id> and says never to post around the composer with a data tool; the agent will otherwise reach for the data tool while the user is still dictating. Values land through the element's native value setter plus an input event, so your controlled onChange sees them exactly like typing.

tsx
<Textarea autoFocus value={body} onChange={(e) => setBody(e.target.value)} voice={{ id: "comment-body", label: "the comment" }} />

Choosing and dating by voice — voice on Select and DatePicker

A Select and a DatePicker speak by their label by default; the same voice={{ id, label }} prop names them yourself, and the phrases are the pointer's own moves, so nothing new has to be wired:

  • Select — "set the status to completed", "change status to open", "update the status to cancelled", "make status completed" pick an option through your onChange; "change the status" / "update status" / "edit the status" opens the list, and while it is open the bare option labels ("completed") pick and "close" dismisses — everything outside the list goes quiet meanwhile, exactly like a modal. The agent's door is field:<id>@<option label or value>. When an option's spoken name differs from its label, give it voiceAliases — an option { value: "4", label: "Urgent", voiceAliases: ["4", "four"] } answers "set priority to 4", "set priority to four", and "set priority to urgent".
  • DatePicker — "set the due date to next friday", "change due date to september 12th", "update the due date to tomorrow", "move due date to the 15th" resolve the spoken date (weekdays, month-and-day, "the 15th", "in two weeks", "end of the month", ISO, m/d) and call onChange with the ISO day; a date it cannot read opens the calendar instead, so a miss is visible. "clear the due date" / "no due date" empties it; bare "change the due date" opens the calendar. Add aliases for the words your domain uses ("due", "postpone to") — each becomes another template. The agent's door is field:<id>@<spoken date or ISO>.
tsx
<Select label="Status" options={STATUS_OPTIONS} value={status} onChange={setStatus} voice={{ id: "status", label: "the status" }} />
<DatePicker label="Due date" value={due} onChange={setDue} voice={{ id: "due-date", label: "the due date", aliases: ["due", "postpone to"] }} />

One rule keeps a form and a list filter from colliding on the same screen: fields own the mutation verbs (set / change / update / make / switch / move) and filters own the filter verbs ("filter by completed", "only open", "status completed", "filter status to completed"). A FilterControl never claims "set status to completed" — that phrase belongs to the open record's Status field.

Full-area records — detailFills

Some records deserve the whole surface: a pull request's side-by-side diff reads badly beside a list column. RecordBrowser detailFills (SplitView's prop of the same name) gives the open record the entire area on every surface — the list unmounts until the crumb root, the close X, or a spoken "close" brings it back. Closing re-renders the list from your records, but its scroll position starts over. That's deliberate: the hidden list's rows and filters drop out of the voice targets, so they never crowd out the open record's own. Nothing else changes: the record header band, crumbs, and voice dismiss target are the same ones the side-by-side layout uses.

tsx
<RecordBrowser detailFills … />

RecordPage, RecordHeading, DetailRow, LoadingPane — the record view

An open record renders as a full view built from the record shells:

  • RecordPage — the one record-page frame: a breadcrumbs row (root part navigates back), an optional pinned heading band, the scrolling body, and an optional pinned actions row. It also owns the loading and load-error states (spinner, then an error panel with Retry) so you only render the loaded case.
  • RecordHeading — the title block: full title, an optional muted meta line ("From Dana - Yesterday"), and a trailing actions slot.
  • DetailRow — labeled rows on a shared label column, so values line up ("To", "Subject", "Due").
  • LoadingPane — the centered spinner filler for any pane that's still loading.
tsx
<RecordPage
  root="Invoices"
  onBack={() => setOpenId(null)}
  title={invoice?.vendor ?? ""}
  loaded={invoice != null}
  loadError={error}
  errorTitle="Couldn't load this invoice"
  loadingTitle="Loading"
  onRetry={reload}
  heading={invoice && <RecordHeading title={invoice.vendor} meta={invoice.dueDate} />}
  actions={<Button onClick={markPaid}>Mark paid</Button>}
>
  {invoice && (
    <>
      <DetailRow label="Amount">{invoice.amount}</DetailRow>
      <DetailRow label="Status">{invoice.status}</DetailRow>
    </>
  )}
</RecordPage>

Inside a RecordBrowser detail pane, pass hideCrumbs — the browser renders its own record header.

useLiveRecord + useRecordRefresh — the live record view

Two hooks make an open record stay correct while the world changes under it — the agent edits the same draft, a sync updates the record, another device deletes it:

  • useLiveRecord (from @avihq/ui/panel) owns the record lifecycle. You supply fetch plus two callbacks: onLiveApply (apply fresh data to the form — only called while it's safe, never over the user's own un-saved typing) and onGone (the record vanished — navigate away, or offer to keep unsaved work). It returns the loading/error state, refresh (silent, change-detected), reload (hard, with a spinner), editing/touched markers for your form handlers, and beginSelfMutation for wrapping your own delete/send so it isn't reported back to you as "the record vanished".
  • useRecordRefresh (from @avihq/apps-sdk/react) wires every standard refresh nudge in one call: changes to the data stores you list in stores, the live events you name in events (names or a predicate), the tab becoming visible again, a reconnect, and a slow safety poll (every 30 seconds while visible). Only listed stores and events count.
tsx
const live = useLiveRecord<Invoice>({
  id: invoiceId,
  fetch: async () => {
    const r = await avi.apps.invoke("billing_read-invoice", { invoice_id: invoiceId });
    return r.deleted ? { record: null, goneReason: "deleted" } : { record: r.invoice };
  },
  onLiveApply: (invoice) => setFields(fieldsOf(invoice)),
  onGone: ({ reason }) => setOpenId(null),
});
useRecordRefresh(() => void live.refresh(), { stores: ["invoices"] });

Never hand-roll setInterval pollers or visibility listeners — this pair replaces all of it, and refreshes arrive already debounced.

ConfirmDialog and DeleteArmedButton — destructive actions

ConfirmDialog is the standard destructive confirmation: one question that carries the verb ("Delete invoice?"), one Confirm button, one way out. DeleteArmedButton composes it into the standard two-step trash button — click to arm, confirm to act. Its label names the button and becomes the dialog's question:

tsx
<DeleteArmedButton
  label="Delete invoice"
  busyLabel="Deleting…"
  busy={deleting}
  onConfirm={deleteInvoice}
/>

Render the same delete affordance in every mode of a view (read and edit) — and always route destructive actions through the dialog rather than acting on first click.

ReplyContext — the reply-draft convention

When a draft replies to something (an email, a message, a comment), render the original read-only below the draft inside ReplyContext — a labeled band (label, default "In reply to", with an optional muted meta beside it — the sender, a date) wrapping your own read-only rendering. Every app with reply-style drafts uses the same shape, so users always know where the original is. One layout rule: the band never shrinks, so every sibling in the same scrolling column must be unshrinkable too (flex: "1 0 auto" for the growing region, flexShrink: 0 for the rest) — otherwise the band squeezes your draft to zero height instead of the page scrolling.

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.

Coloring code line by line

CodeBlock colors a whole string. For a reading that draws its own rows — a diff laid into a file, a side-by-side view — use the line highlighter:

tsx
import { highlightLines, languageForFilename, HighlightedLine } from "@avihq/ui/panel";

const lines = highlightLines(text, languageForFilename(path)); // one token list per line
<td><HighlightedLine tokens={lines[n - 1]} /></td>

The text is tokenized whole, so block comments and template strings stay right across lines. Colors are the --color-syntax-* theme tokens (light and dark), so highlighted text reads on the panel's own background. Unknown languages come back as plain lines.

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.
useLiveData(loader, options?)Run a loader on mount and re-run it on live events — see Live updates.
useLiveEvents(handler)The raw stream of the app's own live events — see Live updates.
useAviEvents(handler)Nudges for declared project activity — see Live updates.
useRecordRefresh(refresh, options?)Every standard record-refresh nudge in one call — see Records.
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 — the read-only archive of Avi's former built-in tasks (tasks now live in the Tasks app).
useProjectContacts()Shortcut for useAvi().contacts — the read-only archive of Avi's former built-in contacts (contacts now live in the Contacts app).
useProjectNotes()Shortcut for useAvi().notes.
useProjectFiles()Shortcut for useAvi().projectFiles — the project's shared files.
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/listtasks:readRead the read-only archive of Avi's former built-in tasks (tasks now live in the Tasks app).
avi.contacts.get/listcontacts:readRead the read-only archive of Avi's former built-in contacts (contacts now live in the Contacts app).
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)NoneInvoke one of this app's own tools.
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", { // this app is "customers"
  status: "active",
});

Tool names use Avi's runtime format:

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

The tool must belong to this app — a panel may only invoke its own app's tools. (Cross-app invocation was removed.)

Tools default to callable from the agent, panels, and the app's own background handler. 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 },
    outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
    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.

Theme and styling

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

Tailwind classes work in panel code. When you deploy, Avi compiles the Tailwind utilities your source uses and ships them inside the panel bundle, so className behaves like a normal Tailwind v4 project — layout, spacing, responsive variants (md:), and states (hover:, focus:):

tsx
<div className="flex h-full flex-col">
  <div className="flex items-center justify-between gap-2 p-3">
    <span className="heading-sm text-txt-primary truncate">Invoices</span>
    <span className="text-xs text-txt-secondary shrink-0">Synced just now</span>
  </div>
</div>

Colors go through Avi's token utilities so panels always match the host theme, dark mode included: bg-bg-primary, bg-bg-secondary, text-txt-primary, text-txt-secondary, text-txt-danger (also -success, -warning, -info), border-bdr-primary, and heading-xs through heading-3xl for titles. The stock Tailwind palette (bg-red-500 and friends) is deliberately absent — only black and white survive.

Two rules, both standard Tailwind: write complete class names in string literals (in template literals, keep each branch a full name — `${dense ? "gap-1" : "gap-3"}`), and never assemble a class name from fragments at runtime — the compiler only ships names it can see whole in your source.

The @avihq/ui components use Avi's design tokens automatically. For dynamic values Tailwind can't express (measured widths, computed positions), inline styles with the same token CSS variables are the fallback — 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: 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 and can't open their own network connections, so 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 simple 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
  });
}

For an open record, useRecordRefresh is the standard bundle of nudges — see Records.

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. A notify dot is transient: a reload clears it. For attention that should last until it's dealt with (unread items waiting), set the durable badge instead — it persists across reloads until the app clears it:

ts
await context.live.publish("inbox.updated", { badge: true });  // set it from your unread state
await context.live.publish("inbox.updated", { badge: false }); // clear it when that state drains

Panels' declared subscribeEvents matches also light the dot (see Project activity below).

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.

Voice

Panels built with the library are voice-operable automatically: users can open records by name, drive filters and sorts, press declared buttons, and confirm destructive actions entirely by speech, and the agent knows what the panel is showing. The components carry all of it — see Voice in App Panels for what users can say and how to make your panel more voice-capable.

When a list publishes more than the host can carry

The host reads at most 150 voice targets from a panel. The registry publishes the dismiss targets first, then the chrome (tabs, crumbs, actions) whole, then the rows. A filter with one option per value (a repo filter over a large account) can be most of that budget on its own, so FilterControl marks its per-option targets deferrable: they come after the rows and share the rows' cap. Set deferrable: true on any chrome of your own that a big list should outrank.

Naming a pane people scroll by voice

The host's scroll grammar knows "the page", "the screen", "the list", "the panel", and "it". A RecordBrowser list already answers to its title and row noun ("scroll the invoices down"), so register a named container only for other panes. A pane people call by another name (a file tree, a column) registers as a named container: an item target with noun column whose label and phrases are its names, and the pane stamped data-voice-scroll="column" with data-voice-column-id equal to that target's id. "Scroll the files down", "scroll down in the file tree", "page down in the folders", "to the top of the files" then resolve in the host and the runtime scrolls the pane. Keep the names within the eight-phrase budget, article-free.

Build and deploy

avi build bundles app.ts into dist/app.mjs for Avi's app 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, and uploads the app and its 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.

Lazy chunks for heavy code. Heavy, rarely used code (a document engine, a chart library) can ship as a lazy chunk instead of inside the panel bundle: declare chunks: { "<id>": "./ui/<entry>.ts" } on the panel and load it with await avi.chunks.load("<id>") when the feature is used. A panel takes up to four chunks of up to 16 MB each, and none of it counts against the panel bundle. Chunks can't import React, the SDK, or the UI library — expose plain functions and DOM, and render them from the panel.

Run:

bash
npm run typecheck
avi build
avi deploy

After deploy, enable the app in a project. If the app defines panels, their icons appear on the project's rail.

Security model

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

  • the sandbox allows scripts, forms (handled by your own code — a form never submits anywhere), links that open in a new tab, and downloads the user starts, and gives the panel an isolated origin of its own;
  • panel code cannot access the parent DOM;
  • panel code cannot read Avi cookies, auth tokens, or local storage;
  • the content security policy blocks the panel's own network connections (the one exception is downloading a project file through a short-lived link from the bridge); scripts run only from the panel's bundle and its declared chunks, and images and media may load from https: URLs;
  • 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 panel4 MB
Lazy chunks per panel4, up to 16 MB each
Panel title length60 characters
Entry extensions.tsx, .ts, .jsx, .js
VisibilityEvery app enabled in the project

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, 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

Start from the starter project:

bash
avi init my-app --dir my-app
cd my-app
npm install
avi deploy

Then grow it the way Avi's own apps are built:

  • a RecordBrowser list, with the open record kept in useAviView;
  • a RecordPage for the open record, kept live with useLiveRecord and useRecordRefresh;
  • the panel and the tools on the same data store (avi.data.* in the panel, context.data.* in tools).