Mihe

Screens and components

Tell the assistant where the user is, and what it can point at.

Screens

Call useAssistantScreen once on each page or view:

useAssistantScreen({
  screenId: "inventory.products",
  route: "/inventory/products",
  title: "Products",
  description: "Manage the catalog, pricing and stock levels.",
  visibleData: { productsCount: 128, lowStockCount: 7 },
});

visibleData is what makes answers concrete: "you have 7 low-stock products" rather than "check your stock levels". Keep it to the few summary values already on screen, such as counts, totals and statuses. It is sent on every screen change, and it goes to the model, so never put anything there the user cannot already see.

Say when the data has not arrived. A page usually declares itself before its queries resolve, and the counts it computes from empty lists are zeros. Pass loading: true until the data is in:

useAssistantScreen({ /* … */ visibleData: { lowStockCount }, loading: isLoading });

While loading, visibleData is not sent. When the assistant navigates to a screen, it waits for the screen to finish loading before reading it. Otherwise it would tell the user there is nothing on a page that is about to show sixteen rows.

Publish your screens too

The assistant can only navigate to a screen in your published registry. That is deliberate: the model picks a screenId, and the route comes from your registry, so no text the model writes ever becomes a URL.

{
  "screens": [
    {
      "screenId": "orders",
      "route": "/orders",
      "title": "Orders",
      "description": "Every order, with its payment and delivery status.",
      "keywords": ["sales", "purchases"],
      "filters": [
        { "key": "status", "values": ["unpaid", "paid", "refunded"] },
        { "key": "from", "kind": "date" }
      ]
    }
  ]
}

filters lets the assistant open a screen already filtered, as query parameters. A filter is either a list of values, or a date (YYYY-MM-DD) checked by shape. An unknown key or an unlisted value is refused. Declare filters only if your screen reads them from the URL. One that keeps its filters in component state cannot be filtered from outside.

Components

Declare the elements the assistant may talk about or point at, and spread the props it returns onto a real DOM element:

const addProps = useAssistantComponent("products.addButton", {
  type: "button",
  label: "Add product",
  description: "Opens the form to create a new product",
});

return <button {...addProps}>Add product</button>;

type is one of button, input, select, table, card, link, modal or other. Without the spread props the component is described to the assistant but cannot be found, and it will confidently point at nothing.

Components are dropped when the screen changes, so the assistant is never told about a button on the page the user just left.

Tabs

Declare a set of tabs, and the assistant can see which one is open and switch to another. "Show me the stock report" then opens the Stock tab rather than telling the user where to click.

const [tab, setTab] = useState("sales");

useAssistantTabs({
  id: "reports.tabs",
  label: "Report",
  tabs: [
    { value: "sales", label: "Sales" },
    { value: "stock", label: "Stock" },
  ],
  value: tab,
  onChange: setTab,
});

onChange is your own code, so it works however your tabs are built: a component library's tabs, a route segment, a query parameter, or plain state. The assistant only ever asks for one of the values you listed.

Pass the tab that is really showing as value. A switch is reported done only once value matches what was asked, so if your onChange did not take effect, the assistant says so instead of claiming it worked. If onChange throws, your error message is what the assistant hears.

Tabs change what is shown and nothing else, so switching one needs no permission or confirmation. Anything that changes data stays an action.

Tables

Declare a table, and the assistant can answer questions about what is in it: "which of these are unpaid?", "what is the oldest order here?".

useAssistantTable({
  id: "orders.table",
  label: "Orders on this page",
  columns: [
    { key: "orderNumber", label: "Order" },
    { key: "customerName", label: "Customer" },
    { key: "total", label: "Total" },
  ],
  rows: orders,
  rowId: (order) => order.orderNumber,
  rowIdName: "orderNumber",
  total, // when `rows` is one page of a longer list
});

The rows are not sent with the screen. The assistant is told the table and its columns, and reads rows when a question needs them, up to 50 at a time. A table therefore costs a line per answer, not its contents. Only the columns you declare ever leave the browser. Long cells are cut to 200 characters, and what is read is treated as data, never as instructions.

rowIdName says which action input a row's id fills. When the assistant reads rows, it is told which actions this user may run here that take that input. That comes from your registry and the user's permissions, not from the table. Pick a distinctive name, such as orderNumber or ingredientName, rather than name or id, which would match unrelated actions. Leave it out for rows nothing acts on.

opens says which screen a row opens. Records are often acted on by one id and linked by another: an order may be changed by its number and opened by its database id. Declare the screen and the route params, and each row the assistant reads carries the exact params to open it:

opens: { screenId: "orders.detail", params: (order) => ({ id: order.id }) },

For a paged table, the assistant can read the page that is loaded and knows how many rows there are in all.

Charts

Declare a chart, and the assistant can explain it. Pass it the same rows your chart library draws from:

useAssistantChart({
  id: "stock.trend",
  title: "Stock trend",
  data: series,
  x: { key: "month", label: "Month", format: (v) => monthLabel(v) },
  series: [
    { key: "inflow", name: "Stock in", unit: "NGN" },
    { key: "outflow", name: "Stock out", unit: "NGN" },
  ],
});

The SDK summarises each series in the browser: where it starts and ends, its high and low, its total, and its largest rise and fall. That summary is what the assistant gets, a few dozen tokens a series, and it answers most questions about a trend. Models are unreliable at arithmetic over a list of numbers, so the summary is computed for them rather than left to the model. For an exact value ("what was it in March?") the assistant reads the points, thinned to 60 a series. A picture of the chart is never sent.

Set x.kind: "category" for a chart of things rather than a sequence (top products, expenses by category), so it is not described as rising and falling.

Figures shown beside a chart, like stat cards, often live in a component below the page that declares the screen. useAssistantValues("stockSummary", { … }) adds them to the screen's values from wherever they are rendered. Searching the rest is not built yet.

Showing instead of doing

Eight built-in commands, free, and none of them change data:

CommandWhat it does
navigateGoes to a published screen, through your navigate prop.
highlightDraws attention to a declared component, with an optional caption.
focusPuts the cursor in a declared input.
scroll_toScrolls a declared component into view.
open_formOpens a form or dialog, through your openModal prop, so the user can see what it asks.
switch_viewSwitches a set of tabs you declared, through your onChange.
read_tableReads rows of a table you declared, from the browser.
read_chartReads the points of a chart you declared.

Showing stops at opening the form. Filling it in and submitting it is an action, with a permission, a risk tier and a confirmation.

Wire up the two props these use:

<AssistantProvider
  navigate={(route) => router.push(route)}
  openModal={(key) => openDialog(key)} // return false if it is not one you can open
  // …
/>

Without navigate, the SDK does a full page load, which works and throws away your app's state.

On this page