Client actions
A client action is a function your page registers at runtime and the assistant can call. It runs in the browser, in the signed-in user's own session, with whatever cookies, CSRF token and client state your app already has. Use it for the work that only the page can do: navigate the SPA, open a modal, highlight a result, or perform a mutation your front end already knows how to perform safely.
The API is WebMCP-aligned. Where the browser exposes a native
navigator.modelContext, the same call registers there too, so your page is a
real WebMCP page for any agent that speaks the standard.
Client actions work under the widget and under the Chrome extension. The extension collects them from the page even when no widget is embedded.
Register one
npm install appilot
import { registerTool } from 'appilot';
const handle = registerTool({
name: 'open_booking',
description: 'Open a booking by id so the user can see it.',
inputSchema: {
type: 'object',
properties: { booking_id: { type: 'string' } },
required: ['booking_id'],
},
annotations: { readOnlyHint: true },
async execute({ booking_id }) {
router.push(`/bookings/${booking_id}`);
return { content: [{ type: 'text', text: `Opened booking ${booking_id}.` }] };
},
});
registerTool returns a handle immediately. Nothing is sent anywhere at
registration: the descriptors are collected from the page at the start of each
turn.
The descriptor
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | Yes | The name the model calls. Lowercase, digits and underscores, starting with a letter, up to 64 characters (^[a-z][a-z0-9_]{0,63}$). |
description | string | Yes | What the action does and when to use it, in the model's terms. Up to 600 characters. |
inputSchema | JSON Schema object | No | The arguments. An object schema; omit it for an action that takes none. |
annotations.readOnlyHint | boolean | No | true marks the action as reading only. Absent or false means it mutates. |
execute | function | Yes | Your handler. Receives the parsed arguments, returns a result or a promise of one. |
Registration is idempotent per name: registering the same name again replaces the previous definition.
Read-only and the confirmation rule
annotations.readOnlyHint decides whether the user is asked before the action
runs. It is the whole of the safety contract, so set it deliberately.
readOnlyHint: truereads or navigates and changes nothing the user would want to approve. The agent calls it directly.- Absent, or
false, means the action mutates. The declaration the model receives carries explicit guidance to confirm with the user first, and the confirmation is shown in the assistant's action dock before the handler runs.
An action that spends money, sends a message, or deletes something is mutating. When in doubt, leave the hint off.
The result
Return the WebMCP content shape and the text is what the model reads:
return { content: [{ type: 'text', text: 'Paid. Receipt 4471.' }] };
Anything else you return is passed through as JSON, which is convenient for structured data the model should reason over:
return { status: 'paid', receipt_id: 4471 };
A handler that throws is reported to the model as a failed action with the error message, and the turn continues. Your page is never left in a broken state by an action that could not run.
Unregister on route change
The registry is a property of the page, and it does not clear itself on an SPA navigation. Register the actions a view supports when it mounts, and unregister them when it unmounts, so the model is never offered an action that belongs to a screen the user has left.
useEffect(() => {
const handle = registerTool({ /* ... */ });
return () => handle.unregister();
}, []);
unregisterClientTool(name) removes one by name where you do not hold the
handle, and onClientToolsChange(listener) tells you when the registry changed.
Limits
- Secure context only. Registration is a no-op on a page that is not HTTPS,
localhost, a*.localhostname or a*.lvh.mename. This matches the standard. - Sixteen actions per turn. Extra descriptors are dropped, and the drop is recorded on the turn so it is diagnosable in the Backoffice under AI Interactions rather than being invisible.
- Reserved names are refused. A client action cannot take the name of an Appilot internal tool or of one of your own server-side tools, so a page cannot shadow something the agent already trusts.
- The text you register is untrusted. Names and descriptions are validated, sanitized and length-capped server-side before they reach the model.
When an action you registered does not appear to exist, open the turn in the Backoffice under AI Interactions and read the answer assembly. Dropped client tools are listed there with the reason.
Without a bundler
The registry is anchored on window.__APPILOT_WEBMCP__ so that every party in
the page can reach it: the widget bundle shares the page realm, and the
extension reaches it from a main-world bridge. That global is the integration
point when you cannot import the package, and it holds a tools map keyed by
action name:
<script>
window.__APPILOT_WEBMCP__ = window.__APPILOT_WEBMCP__
|| { version: 1, tools: new Map(), listeners: new Set() };
window.__APPILOT_WEBMCP__.tools.set('open_booking', {
name: 'open_booking',
description: 'Open a booking by id so the user can see it.',
inputSchema: { type: 'object', properties: { booking_id: { type: 'string' } } },
annotations: { readOnlyHint: true },
execute: ({ booking_id }) => {
location.href = '/bookings/' + booking_id;
return { content: [{ type: 'text', text: 'Opened booking ' + booking_id + '.' }] };
},
});
</script>
Prefer the package where you have a build step. It validates the descriptor,
mirrors the registration into a native modelContext when the browser has one,
and gives you a handle to unregister with.
Client actions and server-side tools
Appilot has two ways to let the agent act, and the difference is where the credential lives.
| Client action | HTTP-proxy tool | |
|---|---|---|
| Declared in | Your page, at runtime | The Backoffice or MCP, as a Tool entity |
| Runs in | The browser, in the user's session | Appilot's backend, against your API |
| Credential | The user's own session | A vault credential you configure once |
| Right for | Navigation, UI state, mutations the page already performs | Anything a browser session should not be able to do alone |
| Works when the page is closed | No | Yes |
A backend operation belongs in an HTTP-proxy tool, where the credential stays
out of the page and X-Appilot-Actor names the end user it acts for. See
Managing tools.
Curating the description
The page-declared form above is the fast one. For production you can also
pre-register the action in the Backoffice as a Tool of kind client_action:
the name, description and schema are then authored by you and stored server
side, and the page supplies only the handler. The model-facing text is then
under your control rather than the page's, which is what you want on a page
that renders anything a third party wrote.
Under the extension
The extension collects the page's registered actions at the start of every turn
through a main-world bridge and invokes them the same way the widget does. The
only requirement is that the page loaded the appilot runtime (or wrote to the
global above) so the registry exists.
Nothing else changes: no widget, no widget key, no extra configuration. See The extension for your app.
Related
- Focused Sessions: mission-scoped conversations that can use these actions.
- Managing tools: the server-side Tool entity.
- End to end with the widget: a client action in the context of a whole integration.