Custom Module React UI
Custom modules can ship React panels that appear in Avi's project chat sidebar. Use panels when a module 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 module-owned records, queues, dashboards, or sync status;
- edit structured data with controls that are awkward through chat alone;
- run a module-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 module with a panel:
my-module/
module.ts
ui/
Dashboard.tsx
package.json
tsconfig.json
The panel entry lives under ui/ by convention, but any relative path inside the module directory is allowed.
Declare panels in module.ts
Declare UI panels on the same defineModule call as your tools:
import { defineModule, type ModuleToolContextForCapabilities } from "@avihq/custom-modules-sdk";
const capabilities = ["data:read", "data:write"] as const;
export default defineModule({
name: "customers",
description: "Customer records with chat tools and a sidebar UI.",
capabilities,
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: ModuleToolContextForCapabilities<typeof capabilities>) {
return {
customers: await context.get("customers:v1"),
};
},
},
},
});
Panel ids are the keys under ui.panels. They must be kebab-case, just like module and tool names.
Panel definition
Each panel has:
| Field | Required | Notes |
|---|---|---|
title | Yes | Shown in the sidebar frame header. Maximum 60 characters. |
icon | No | A supported sidebar icon name, a relative image path, an HTTPS image URL, or a small data:image/... URI. Defaults to panel-right. |
entry | Yes | Relative .tsx, .ts, .jsx, or .js path inside the module directory. |
Built-in icons:
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.
A module can define up to five panels.
Write the React component
Panel code is normal React. Export a default component from the entry file and import Avi UI helpers from @avihq/custom-modules-sdk/react:
import { useEffect, useState } from "react";
import {
Button,
EmptyState,
Input,
PanelHeader,
Spinner,
Table,
Toolbar,
useAvi,
} from "@avihq/custom-modules-sdk/react";
interface Customer {
id: string;
name: string;
owner: string;
}
const STORE_KEY = "customers:v1";
export default function CustomersPanel() {
const avi = useAvi();
const [customers, setCustomers] = useState<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [name, setName] = useState("");
async function load() {
setLoading(true);
const stored = await avi.data.get<Customer[]>(STORE_KEY);
setCustomers(stored ?? []);
setLoading(false);
}
async function save() {
const next = [{ id: crypto.randomUUID(), name, owner: "Unassigned" }, ...customers];
setCustomers(next);
await avi.data.set(STORE_KEY, next);
await avi.toast.show({ type: "success", message: "Customer saved" });
setName("");
}
useEffect(() => {
void load();
}, []);
return (
<div>
<PanelHeader title="Customers" description="Records owned by this module." />
<Toolbar>
<Input value={name} onChange={(event) => setName(event.currentTarget.value)} />
<Button onClick={save} disabled={!name.trim()}>Save</Button>
</Toolbar>
{loading ? (
<EmptyState title="Loading">
<Spinner />
</EmptyState>
) : customers.length === 0 ? (
<EmptyState title="No customers" />
) : (
<Table>
<tbody>
{customers.map((customer) => (
<tr key={customer.id}>
<td>{customer.name}</td>
<td>{customer.owner}</td>
</tr>
))}
</tbody>
</Table>
)}
</div>
);
}
The CLI wraps your component with React's createRoot, so the entry file should export the component rather than calling createRoot itself.
SDK React exports
The React entry point exports two groups of APIs.
UI primitives:
| Export | Purpose |
|---|---|
Button | Themed button with variant and size props. |
Input | Themed text input. |
Textarea | Themed textarea. |
Select | Themed native select. |
Switch | Themed checkbox-style switch input. |
Tabs | Layout wrapper for tabbed interfaces. |
Table | Themed table. |
Badge | Small status label with tone. |
Toolbar | Horizontal action/header row. |
PanelHeader | Standard panel title and description area. |
EmptyState | Centered empty/loading/error state container. |
Spinner | Small loading spinner. |
Hooks:
| Export | Purpose |
|---|---|
useAvi() | Returns the full panel bridge. |
useTheme() | Returns { mode, tokens } for the current Avi theme. |
useModuleData() | Shortcut for useAvi().data. |
useModuleRecords() | Shortcut for useAvi().records. |
useProjectUpdates() | Shortcut for useAvi().updates. |
useProjectTasks() | Shortcut for useAvi().tasks. |
useProjectContacts() | Shortcut for useAvi().contacts. |
useModuleTool() | Shortcut for useAvi().modules.invoke. |
These primitives are intentionally small. For custom layout, use regular React and CSS with Avi theme variables.
Bridge API
Panels cannot call Avi APIs directly. Use useAvi() to make bridge calls through the parent app:
const avi = useAvi();
Available bridge methods:
| API | Capability | What it does |
|---|---|---|
avi.data.get(key) | data:read | Read project/module JSON data. |
avi.data.set(key, value) | data:write | Write project/module JSON data. |
avi.data.delete(key) | data:write | Delete project/module JSON data. |
avi.data.listKeys(options) | data:read | List project/module data keys. |
avi.records.collection(name).get/list/search | records:read | Read declared module records and run full-text/vector search. |
avi.records.collection(name).create/update/delete | records:write | Write declared module records. |
avi.updates.list(options) | updates:read | Read project timeline updates. |
avi.updates.create(input) | updates:write | Publish a project timeline update. |
avi.tasks.get/list/search | tasks:read | Read and search project tasks. |
avi.tasks.create/update/delete | tasks:write | Create, edit, or delete project tasks. |
avi.contacts.get/list | contacts:read | Read contacts in the project tree's root contact pool. |
avi.contacts.create/update/delete | contacts:write | Create, edit, or delete contacts in the project tree's root contact pool. |
avi.files.get(key) | files:read | Read a project/module file as string data. |
avi.files.put(key, blob) | files:write | Write a project/module file. |
avi.org.projects.list() | org:projects:read | List shared projects in the org. |
avi.modules.invoke(tool, input) | modules:invoke | Invoke another enabled custom-module tool. |
avi.user.current() | None | Return the current user id, or null. |
avi.toast.show(input) | None | Show an Avi toast in the parent app. |
The bridge enforces the same approved capabilities as tool handlers. If a panel calls avi.data.set but the module was not approved for data:write, the call fails.
Bridge values must be JSON-compatible except file blobs, which use:
{
data: string;
contentType?: string;
}
Shared state with tools
Panels and tools use the same module data scopes. For app-like data, prefer declared records so panels and tools can share CRUD, filters, full-text search, and vector search.
For example, a panel can write:
await avi.records.collection("customers").create({
name: "Northstar Labs",
status: "active",
notes: "Expansion candidate",
});
Then a tool in the same module can read:
const customers = await context.records.collection("customers").search({
text: "expansion",
vector: "accounts likely to expand",
});
Project-scoped data is isolated by project and module. Org-scoped data is isolated by org and module.
Invoking tools from a panel
Use avi.modules.invoke only when the panel needs behavior already exposed as a tool:
const result = await avi.modules.invoke("customers_list-customers", {
status: "active",
});
Tool names use Avi's runtime format:
<module-name>_<tool-name>
The target module must be enabled for the current project, and the current module must have the modules:invoke capability approved.
Tools default to callable from both the agent and module UI. To keep a panel helper out of the agent's tool list, mark it as UI-only in module.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-module composition.
Theme and styling
Avi sends theme tokens into the iframe and keeps them updated when the user changes theme.
The SDK components use these variables automatically:
hsl(var(--background))
hsl(var(--foreground))
hsl(var(--muted-foreground))
hsl(var(--border))
hsl(var(--primary))
For custom CSS, use the same variables:
<div style={{ borderBottom: "1px solid hsl(var(--border))" }}>
<span style={{ color: "hsl(var(--muted-foreground))" }}>Synced just now</span>
</div>
Use useTheme() if the component needs to branch on light or dark mode:
import { useTheme } from "@avihq/custom-modules-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.
Build and deploy
avi build bundles module.ts into dist/module.mjs for the Lambda runtime and validates the panel manifest shape: ids, titles, icons, and entry paths.
avi deploy rebuilds the module, 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 module revision.
Run:
npm run typecheck
avi build
avi deploy
After deploy, enable the module in a project. If the module 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:
| Limit | Value |
|---|---|
| Panels per module | 5 |
| Bundle size per panel | 1 MB |
| Panel title length | 60 characters |
| Entry extensions | .tsx, .ts, .jsx, .js |
| Visibility | Own-org modules only |
Build-time validation rejects:
- absolute panel entry paths;
- paths that leave the module directory;
- unsupported file extensions;
- invalid icon values, such as empty strings, very large values, absolute paths, paths outside the module directory, unsupported URL schemes, or non-image data URIs;
- Node built-in imports such as
fs,path, ornode:crypto; - bundles over the size limit.
Troubleshooting
The panel icon does not appear
Confirm the module is enabled for the project, belongs to the current org, deployed successfully, and has ui.panels in module.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 defineModule({ capabilities }), redeploy, and approve the capability when enabling the module.
The panel renders but does not share data with tools
Check that both sides use the same key and scope. avi.data corresponds to context.get / context.set; avi.records corresponds to context.records.
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.
Example
The repo includes a complete example at packages/tools/customers:
module.tsdeclares acustomersmodule, adashboardpanel, and CRUD tools.ui/CustomersPanel.tsxrenders a sidebar UI with search, editing, persistence, and toasts.- Both the panel and tools read and write the same
customers:v1project-scoped data key.
Deploy it with:
cd packages/tools/customers
npm install
avi deploy