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_messagestarts a new turn withtext, as though the user typed it.open_url,open_source,download_fileare chat handlers: open a link, open a source in the viewer, or download a file.approve_tool,reject_tool,request_changeanswer a pending approval. A rejection can carry areason.emit_eventsends an event to the page that embeds the chat, for example{ type: 'emit_event', name: 'order_selected', id }, oremitEvent('order_selected', { id })from code. The page receivesassivy:widget.event, the same way it receives tool results (see channels). Inside Assivy itself it does nothing.- Any other
typeruns 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:
| Name | Use |
|---|---|
todo-list | Loads todos through two server actions and drills into one. The reference example for server actions. It needs dummyjson.com allowed under Outbound access. |
search-results | Titled result list with relevance and an Open button. The default for web_search and search_knowledge. |
detail-card | Labelled fields from the runtime user object. |
create-event | A day cell with events and add/discard actions. |
tool-approval | Shows 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.