Assivy

Widgets

Server-driven UI trees that render tool results in chat.

These aren't the embeddable chat widget (channels). Server-driven widgets are rich UI inside the chat: instead of a JSON blob, a tool result shows up as cards, rows, buttons, and text. The template is turned into a component tree, and the chat renderer draws that tree. Unknown component types are skipped, so new components degrade gracefully on old clients.

Authoring

A widget has a JSX template, a state jsonSchema, and sample state for the preview. The tool's output keys are in scope as bare variables (write {user.name}), along with these bindings: useState/setState, sendMessage, serverAction, useServerData, approveTool/rejectTool, openUrl/openSource/downloadFile, clientAction, and emitEvent. Reserved names are excluded so a render never crashes. A template is either a single JSX expression or a body with a top-level return.

A library widget's name uses lowercase letters, digits, and hyphens, is up to 64 characters, and is unique in the org. displayTitle (up to 128 characters) is the label shown in the library, and iconName is a Lucide icon name such as search.

Where it renders

By default the API renders the template in the sandbox after the tool returns, and streams the finished tree into the chat. With runOnClient, the browser evaluates the template against the tool's output instead.

Browser rendering is forced when a template builds a handler as a function (onClick={() => …}) or uses any of the bindings above. Those trees hold closures, which only exist where the tree was built. runOnClient is ignored for the search_knowledge and web_search results cards, where the tool decides, and for approval widgets, which always render in the browser.

A browser-rendered tree isn't saved with the message. It's rebuilt from the stored tool output on every load, so editing the template changes old messages too.

Actions

Buttons and inputs take a handler as onClick/onChange/onSubmit (or the onClickAction form). A handler is either a function or a config object like { type: "send_message", text: "Show more" }. The config forms are:

  • send_message starts a new turn with text, as though the user typed it.
  • open_url, open_source, download_file are chat handlers: open a link, open a source in the viewer, or download a file.
  • approve_tool, reject_tool, request_change answer a pending approval. A rejection can carry a reason.
  • emit_event sends an event to the page that embeds the chat, for example { type: 'emit_event', name: 'order_selected', id }, or emitEvent('order_selected', { id }) from code. The page receives assivy:widget.event, the same way it receives tool results (see channels). Inside Assivy itself it does nothing.
  • Any other type runs the widget's server action of that name.

Server actions

A stored widget can declare up to 10 server actions. Each has a lower_snake name (up to 64 characters), an optional description, and an execute body of up to 20,000 characters. A template loads data with useServerData('list_orders', { limit }), which returns { data, loading, error }, or calls await serverAction('name', payload) from a handler.

const limit = Number(input.limit) || 10;
const res = await fetch(`https://api.example.com/orders?limit=${limit}`);
if (!res.ok) throw new Error(`Orders service returned ${res.status}`);
return { orders: (await res.json()).orders };

The body follows the function tool rules: TypeScript statements over input (the payload), run in the Docker sandbox. fetch reaches only the hosts allowed under Outbound access, runtime carries the verified caller, and log() (not console) writes to the platform logs. Results over 128 KB are refused. The endpoint, POST /widgets/:id/actions/:action, is plain RPC and always answers HTTP 200 with {data} or {error}, because its callers are render and event handlers.

Actions are deliberately ungated

The action endpoint has no permission check, because embed visitors need to reach it. Its safety comes from its shape: the browser sends an action name, never code, and only actions on stored widgets can be called. Inline { definition } widgets have no id, so they can't run server actions.

Binding to tools

Tools reference a widget in one of three ways:

  • { id } points at a library widget, so editing that widget updates every tool that uses it.
  • { definition } is an inline widget.
  • { hidden: true } shows nothing for the tool in a normal transcript. Verbose and thinking modes still show the call, and approval and Connect cards always show.

With no binding the tool shows its default card. That's on purpose: no binding doesn't mean hidden.

Built-ins

Every org gets six built-in widgets:

NameUse
todo-listLoads todos through two server actions and drills into one. The reference example for server actions. It needs dummyjson.com allowed under Outbound access.
search-resultsTitled result list with relevance and an Open button. The default for web_search and search_knowledge.
detail-cardLabelled fields from the runtime user object.
create-eventA day cell with events and add/discard actions.
tool-approvalShows the pending call and its input, with Approve, Reject, and Request changes plus a reason box.
default-greeting"Launch Chat": the channel greeting title and message plus the chat input.

You can edit built-ins but not delete them. A builtinCustomized flag marks your edits, and reset (POST /widgets/:id/reset) restores the original. When you delete a custom widget, tools that used it fall back to the raw JSON card. If you're stuck on a template, the Generate tab chats with a builder (POST /widgets/generate/chat, which keeps no state). The builder proposes a widget, and nothing is saved until you save it.

On this page