Skip to main content

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​

FieldTypeRequiredMeaning
namestringYesThe name the model calls. Lowercase, digits and underscores, starting with a letter, up to 64 characters (^[a-z][a-z0-9_]{0,63}$).
descriptionstringYesWhat the action does and when to use it, in the model's terms. Up to 600 characters.
inputSchemaJSON Schema objectNoThe arguments. An object schema; omit it for an action that takes none.
annotations.readOnlyHintbooleanNotrue marks the action as reading only. Absent or false means it mutates.
executefunctionYesYour 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: true reads 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 *.localhost name or a *.lvh.me name. 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 actionHTTP-proxy tool
Declared inYour page, at runtimeThe Backoffice or MCP, as a Tool entity
Runs inThe browser, in the user's sessionAppilot's backend, against your API
CredentialThe user's own sessionA vault credential you configure once
Right forNavigation, UI state, mutations the page already performsAnything a browser session should not be able to do alone
Works when the page is closedNoYes

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.