> ## Documentation Index
> Fetch the complete documentation index at: https://docs.jun.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Page actions

> Let the AI do things on your page for the visitor, through WebMCP tools your page registers, with confirmation, undo and step-by-step runs.

Your page tells the AI what can be done right here: add to cart, change plan, export a CSV, book a slot. When a visitor asks for one, the AI picks it, the chat asks for anything it couldn't fill in, confirms, runs your function **in the visitor's browser with their own login**, and shows the result.

* The AI can only pick from the list your page registered. It never reads the page or fills in forms itself, unless you turn on [page control](/widget/page-control.md) for the site.
* Your functions never leave the page. Only the name, description, input schema and the extras below are sent to the desk.
* Each run is recorded in the conversation's AI actions, so agents see what happened.

## The standard: WebMCP

Page actions use [WebMCP](https://developer.chrome.com/docs/ai/webmcp/imperative-api), the browser API for exposing a page's tools to AI agents, rather than a Jun-only format. Tools you register for Chrome's own agent work with Jun with no extra code, and actions you register with Jun are visible to other WebMCP agents too.

> [!WARNING]
>
> WebMCP is a W3C draft in a Chrome origin trial, and it changes every few releases. Between Chrome 153 and 156, for example, it moved from `navigator` to `document`, deprecated JSON-string inputs, and dropped `window.ontool*`. Check Chrome's [imperative API](https://developer.chrome.com/docs/ai/webmcp/imperative-api) and [declarative API](https://developer.chrome.com/docs/ai/webmcp/declarative-api) docs before relying on a detail. This page describes the loader as of October 2026.

Where the browser has no native `document.modelContext`, the loader installs a small stand-in so the same code works everywhere. Where the browser has it, the loader uses the native one.

> [!NOTE]
>
> Put the [snippet](/widget/install.md) in `<head>` with `defer`, before your own scripts. Code that checks for `document.modelContext` once on load must find it there.

## Register an action

There are three ways in. All three end up in the same list the AI picks from.

**JunDesk.registerAction**

The WebMCP tool shape plus Jun's extras (pages, instances, highlighting, context, availability, undo, risk, status line, result card). Returns a function that removes the action.

```js theme={null}
const remove = JunDesk.registerAction({
  name: "add_to_cart",
  description: `Add ${product.name} to the cart`,
  inputSchema: {
    type: "object",
    properties: {
      size: { enum: product.sizes },
      quantity: { type: "integer", minimum: 1, maximum: 5 },
    },
    required: ["size"],
  },
  key: product.id,
  element: cardEl,
  context: { price: product.price },
  available: () => product.stock > 0,
  execute: async ({ size, quantity = 1 }) => {
    const line = await cart.add(product.id, size, quantity);
    return { summary: `Added ${quantity} × ${size}`, lineId: line.id };
  },
  undo: (result) => cart.remove(result.lineId),
});
```

`JunDesk.registerAction` exists as soon as the loader has run. Registrations made before the actions code has loaded are queued and replayed.

**document.modelContext.registerTool**

Plain WebMCP. Use this if you already expose tools to Chrome's agent.

```js theme={null}
document.modelContext.registerTool({
  name: "show_usage",
  description: "Scroll to the usage section and show this month's numbers",
  inputSchema: { type: "object", properties: {} },
  annotations: { readOnlyHint: true },
  execute: () => {
    document.getElementById("usage").scrollIntoView({ block: "center" });
    return "Usage: 4,210 events this month";
  },
}, { signal: controller.signal }); // controller.abort() unregisters it
```

Tool names must be unique; registering the same name twice throws. The loader's stand-in also has `getTools()`, `executeTool()` and a `toolchange` event.

**Declarative forms**

WebMCP's declarative API turns a `<form>` into a tool with attributes like `toolname`, `tooldescription`, `toolparamdescription` and `toolautosubmit`:

```html theme={null}
<form toolname="subscribe_newsletter" tooldescription="Subscribe the visitor to the newsletter">
  <input name="email" type="email" toolparamdescription="The email to subscribe" />
  <button>Subscribe</button>
</form>
```

The loader reads tools from `document.modelContext.getTools()`, so forms work where the browser itself supports declarative WebMCP. The loader's stand-in covers the imperative API only.

## What `execute` gets and returns

`execute(input, signal)` runs in the visitor's browser.

* `input` holds the values the AI filled and the visitor confirmed, already checked against your schema (numbers are numbers, enums match exactly).
* The second argument is an `AbortSignal` that also has a `signal` property pointing at itself, so both `fetch(url, { signal: opts })` and `opts.signal` work.
* It may be async. A thrown error or rejected promise shows as **Didn't work** with the error's message, and the AI hears about it.

What you return is what the AI reads next (up to 2,000 characters):

| Return | The AI reads | The chat shows |
| - | - | - |
| A string | The string | The string |
| An object with `summary` | The summary | The summary |
| Any other object or list | Its JSON | Nothing extra (JSON is for the AI only) |

Return JSON when the AI needs data for its next step (search results, a list of slots). Return a sentence when the customer should see what happened.

## Fields

Standard WebMCP fields:

**name** `string` *required*

The action's name, like `add_to_cart`. Up to 64 characters. The AI sees it as `pa_<name>`.

**description** `string` *required*

What it does, in one line (up to 200 characters). The AI chooses by this, and the confirm card shows it.

**inputSchema** `object`

A JSON Schema object. Each property can be `string`, `number`, `integer` or `boolean`, with `description`, `enum` (up to 24 values), `minimum`, `maximum` and `format` (`email`, `date` and `url` get matching fields). Up to 12 properties. Properties listed in `required` that the AI couldn't fill are asked for in the chat.

**execute** `function` *required*

Runs the action. See above.

**annotations** `object`

WebMCP hints. Without `risk`, `consequentialHint: true` means **confirm** and `readOnlyHint: true` means **auto**.

Jun extras (only through `JunDesk.registerAction` or `useJunAction`):

**pages** `string[]`

Offer the action only on matching paths. An exact path (`"/pricing"`) or a prefix ending in `*` (`"/plans/*"` matches `/plans/pro` and `/plans/`). Left out, it's offered everywhere it's registered.

**key** `string | number`

One instance per item on screen, like one per product card. `name` plus `key` must be unique; registering the same pair again replaces the older one. Keyed instances aren't mirrored into `document.modelContext` (WebMCP names must be unique there).

**element** `Element | RefObject`

Highlighted with an outline and scrolled into view while the action runs. Also tells the loader whether the action is on screen, which helps it rank.

**context** `object | function`

Small facts for the AI, like `{ price: 89, inStock: true }`, or a function returning them. Up to 8 entries; strings up to 80 characters, numbers and booleans.

**available** `() =\> boolean`

Checked before the action is offered and again before it runs. When it returns `false` (sold out, already on this plan), the action isn't offered, and a run in flight shows **No longer on this page**.

**undo** `(result) =\> void | Promise`

Gives the run an **Undo** button. Receives what `execute` returned.

**risk** `"auto" | "confirm" | "human"` (default: confirm)

How it runs. See [Risk levels](#risk-levels). Overrides `annotations`.

**status** `string`

The line the chat shows while it runs, like `"Checking fares"`. Up to 60 characters. Without it, one is made from the name: `set_pickup` becomes "Setting pickup", `export_csv` "Exporting CSV".

**widget** `string`

A card made on the desk, by its widget name (like `"cart_summary"`). When `execute` returns an object, the chat shows it as that card (up to 32 KB of JSON). See [Widgets](/ai/widgets.md).

## Risk levels

| Risk | What happens |
| - | - |
| `confirm` (default) | The chat shows what will happen, with the inputs, and **Confirm** / **Cancel**. Nothing runs until the visitor confirms. |
| `auto` | Runs as soon as the AI picks it, once all required inputs are known. Use it for things that are safe and easy to reverse: searching, scrolling, showing. |
| `human` | Nothing runs on the page. The conversation goes to your team with "The customer wants to: *description*". |

When required inputs are missing, the chat asks for them first ("A couple of details first"): a short list of choices (up to 6) becomes buttons, a longer one a select, a yes/no two buttons, a `date` a date picker, an `email` an email field. Then the visitor presses **Continue**.

### Override risk on the desk

Your site sets a default, but the desk decides. Add `website-actions.yaml` to your [agent config](/code/files.md): a map of action name to `auto`, `confirm` or `human`, or to `run:` and `status:`.

```yaml website-actions.yaml theme={null}
upgrade_plan: confirm
export_csv: auto
cancel_subscription: human
set_pickup:
  run: auto
  status: Setting your pickup
```

Actions it doesn't name keep the site's own setting. You can set the same thing without YAML: **Agent → Actions** has an **On your website** section listing every website action the desk has seen, with a picker on each card. The override applies wherever actions are offered: live chats, the Agent page's test chat and `jun eval`.

## After it runs

Once a run starts, it shows as a step under the AI's answer: a state icon, a title made from the name ("Add to cart"), and a short chip with the result (or the readable inputs). Opening a step shows the description, inputs, and result or error. Several steps fold under one "N steps" header. **Undo** sits on the step when you registered `undo`.

| Status | Means |
| - | - |
| Waiting for you | Needs a confirm or missing details |
| Done | `execute` returned |
| Didn't work | `execute` threw |
| Cancelled | The visitor pressed Cancel; it didn't run |
| No longer on this page | The visitor navigated away, the action was removed, or `available` returned false |
| Undone | The visitor pressed Undo |

## Multi-step requests

Some requests take several actions: "find a flight to Lisbon on Friday and add it to my cart" might be a search, then a selection, then an add. After each result, the AI gets one more turn, and in that turn it must decide: call the next action, or finish. So requests chain one action per turn:

1. The visitor asks.
2. The AI calls `search_flights` (auto). It runs and returns JSON.
3. The AI reads the result and calls `add_to_cart` (confirm). The visitor confirms. It runs.
4. The AI writes a short reply, or ends the chain.

Limits that keep chains safe:

* At most 12 AI messages in a row after the visitor's message.
* Calling the same action with the same inputs again ends the chain instead of repeating it.
* Each `confirm` step still waits for the visitor.

With the [island](/widget/launchers.md#island), the whole run stays one live line ("Checking fares… · step 2") and only grows when the visitor has to confirm or fill something in.

## Many actions on one page

The loader sends up to 60 actions with each message: actions on screen first, then the rest in registration order, then other WebMCP tools. The desk picks up to 30 for each reply: words from the visitor's question that match an action's name, description or context count most, being on screen counts too, and registration order breaks ties.

[Actions](/ai/actions.md) defined on the desk (`actions/*.yaml`) can be limited to pages the same way, with `pages: ["/orders/*"]`.

With [page control](/widget/page-control.md) on, its five actions (`read_page`, `click` and the rest) are always offered as well, after your own.

## React

`useJunAction` from `packages/widget-react` (MIT, React 18+ as a peer, no other dependencies) registers an action while the component is mounted and removes it on unmount. If the loader hasn't loaded yet, it waits for the `jundesk:ready` event.

```tsx theme={null}
import { useRef } from "react";
import { useJunAction } from "@jun/widget-react";

function ProductCard({ product }) {
  const ref = useRef<HTMLDivElement>(null);
  useJunAction(
    {
      name: "add_to_cart",
      key: product.id,
      description: `Add ${product.name} to the cart`,
      inputSchema: { type: "object", properties: { size: { enum: product.sizes } }, required: ["size"] },
      element: ref,
      context: { price: product.price },
      execute: async ({ size }) => {
        await cart.add(product.id, size);
        return `Added ${product.name} (${size})`;
      },
    },
    [product.id],
  );
  return <div ref={ref}>…</div>;
}
```

The second argument works like `useEffect`'s dependency list: the action is registered again when it changes.

> [!NOTE]
>
> The package lives in the repo at `packages/widget-react` and isn't published to npm. Copy `src/index.ts` into your app, or install it from the repo. Its types don't list `status` yet; the loader accepts it anyway.

## Card buttons that run page actions

A [widget](/ai/widgets.md) card's button can run one of your page actions: give it the action type `page:<name>`, for example `{"type": "page:remove_from_cart", "payload": {"id": "mug"}}`. The press counts as the visitor's yes, so the action runs at once with the payload and the card's fields, unless a required input is still missing. A `human` action still goes to your team. There's no AI turn for the press.

## Test page actions

Eval cases in `tests/*.yaml` take an `actions:` list and a `page:` path, and can expect an action:

```yaml tests/plans.yaml theme={null}
- name: upgrade asks for the plan
  message: upgrade me to the growth plan
  page: /billing
  actions:
    - name: upgrade_plan
      description: Change this account's plan
      inputSchema:
        type: object
        properties:
          plan: { type: string, enum: [starter, team, growth] }
        required: [plan]
  expect:
    action: { name: upgrade_plan, input: { plan: growth } }
```

See [Tests](/ai/tests.md). To try it by hand, open **Chat widget → Install → Open demo page**: it registers `upgrade_plan` (confirm, with undo), `export_csv` (auto) and a plain WebMCP `show_usage`.

> **[Actions](/ai/actions.md)**
>
> Server-side actions that call your API.

> **[JavaScript API](/widget/javascript-api.md)**
>
> `registerAction` and everything else on `JunDesk`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.