# Browser operations

> Read a page, list tabs, or send a request through a connected browser. Examples and parameters for each operation.

Choose an operation and send it to `POST /call`. Each request names a connected user,
the operation to run and the site the user approved.

New here? [Make your first request](/docs/quickstart/) before exploring the options below.

```json title="Request body"
{
  "user_ref": "YOUR_USER_REF",
  "op": "tabs.list",
  "params": { "domain": "example.com" }
}
```

`user_ref` comes from [connecting the user](/docs/connect/). Apigami runs the operation in that
user's most recently active browser that can reach the site, and keeps that browser for the whole
call. To use one browser in particular, add its `install_ref`: a named browser is never replaced
by another, so an offline one fails with `503 extension offline`.

<details>
<summary>How the browser is chosen</summary>

| Situation | Result |
|---|---|
| `install_ref` given | That browser, or `403 install_ref is not a browser of this user` if it isn't one of theirs. |
| A browser online already allows the site | The most recently active of them. |
| The site was never asked about, one browser online | That browser; it opens the approval window. |
| The site was never asked about, several browsers online | `409`: name one with `install_ref`. |
| No browser attached | `409 no browser is attached to this user`. |
| No browser can reach the site | `409 no browser of this user holds the permission for example.com`. |

</details>

By default, the API wraps each result below in `{ "result": ... }`. `request.fetch` also supports
[raw responses with gzip](#gzip-and-binary-responses). Requests can carry up to 25 MB;
an operation has up to 20 seconds to run in the browser.

## `request.fetch` — run a request in the browser

Send an HTTP request using the account the user is signed in to. The browser supplies the
session, IP address and User-Agent; you receive the response.

```json title="Example params"
{
  "domain": "example.com",
  "url": "https://example.com/",
  "method": "GET"
}
```

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `url` | required | `https://` only, on the domain or a subdomain of it |
| `method` | `GET` | Any HTTP method |
| `headers` | `{}` | Request headers. Cookies, `Origin`, `Referer`, `User-Agent` and `Host` are set by the browser and cannot be overridden |
| `body` | | A string. Encode JSON yourself and set `content-type` |
| `bodyEncoding` | `text` | How Apigami turns `body` back into bytes, **not** the format the site expects. `text` sends the UTF-8 of the string; `base64` decodes it to bytes first. Needs install 0.8.0 or later, which answers `409` otherwise |
| `responseType` | `text` | `text` decodes UTF-8; `base64` preserves file bytes in the JSON envelope; `binary` returns bytes and enables gzip when combined with the outer headers below |

</details>

Result:

```json title="Result"
{
  "status": 200,
  "headers": {
    "content-type": "application/json",
    "…": "…"
  },
  "body": "<response body as text>"
}
```

The default body is UTF-8 text. With `responseType: "base64"`, `body` is base64 and the result
includes `encoding: "base64"`.

A `status` of 403 is the site's answer, and not always about the session. When the response carries
`x-datadome` or `cf-mitigated: challenge`, or its body names `captcha-delivery` or `challenge-platform`,
it is anti-bot protection, not a sign-in problem: open a tab of the site with `tabs.open`, which gives
the protection a real visit to check, then retry. Do not read or copy the anti-bot cookie.

### Sending a file

`bodyEncoding` is a transport setting, between your code and the browser, and nothing more. The site
only ever receives bytes; it never sees the word base64. What the site expects is what `content-type`
says, and `bodyEncoding` only tells Apigami how to rebuild those bytes from a string that had to
travel inside JSON.

| What the target expects | `body` | `bodyEncoding` | `content-type` |
|---|---|---|---|
| The raw bytes of a file | the file, base64-encoded | `base64` | the file's own type, e.g. `application/pdf` |
| JSON with a base64 field inside | the whole JSON string, base64 included | `text` | `application/json` |
| A multipart form | the multipart you assembled, base64-encoded | `base64` | `multipart/form-data; boundary=…` |

The second row is the case where base64 is *content*: it rides as `text`, like any other string.

A request body travels as JSON text, so a PDF or a DOCX put there as a string is encoded UTF-8 and
arrives corrupted. Base64-encode the file and say so with `bodyEncoding`; the browser decodes it to
bytes and sends those:

```json title="Uploading a file"
{
  "domain": "example.com",
  "url": "https://example.com/uploads/abc",
  "method": "PUT",
  "headers": { "content-type": "application/pdf" },
  "body": "JVBERi0xLjcKJc…",
  "bodyEncoding": "base64"
}
```

### Gzip and binary responses

**Request gzip by default in your server's `request.fetch` calls**, including for HTML or JSON.
Send all three settings together:

- `Accept: application/octet-stream` on the outer `POST /call`.
- `Accept-Encoding: gzip` on that same HTTP request.
- `params.responseType: "binary"` in the JSON request body.

`params.headers` are sent to the target site. Putting the gzip headers there does not enable
compression of the Apigami response. Do not set `params.compress`; Apigami decides whether the
connected install supports it.

```bash title="Request a raw response with gzip decoding enabled"
curl --compressed https://api.eu.apigami.com/call \
  -H "Authorization: Bearer $APIGAMI_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/octet-stream' \
  -H 'Accept-Encoding: gzip' \
  -d "{
    \"user_ref\": \"$APIGAMI_USER_REF\",
    \"op\": \"request.fetch\",
    \"params\": {
      \"domain\": \"example.com\",
      \"url\": \"https://example.com/\",
      \"responseType\": \"binary\"
    }
  }"
```

On success, the HTTP body contains the site's bytes, without a `{ "result": ... }` envelope:

| Response header | Meaning |
|---|---|
| `Content-Type: application/octet-stream` | Raw response mode, including for text and JSON |
| `X-Apigami-Status` | The site's HTTP status; Apigami itself returns 200 even if the site returned 404 or 429 |
| `X-Apigami-Headers` | The site's headers, encoded as a JSON object |
| `X-Apigami-Headers-Truncated` | Present when the site's headers were too large to travel in one header; see below |
| `Content-Encoding: gzip` | Present only when the body was compressed |
| `X-Apigami-Body-Downgraded: base64` | An install older than 0.3.0 required conversion; your app still receives raw bytes |

A proxy caps how much a response's header block may weigh, and some sites answer with far more than
that on their own — a single Content-Security-Policy can run to several kilobytes. When the site's
headers do not fit, `X-Apigami-Headers` carries only the ones that describe the body —
`content-type`, `content-length`, `content-encoding`, `content-disposition`, `location`,
`retry-after`, `date` — and `X-Apigami-Headers-Truncated` gives the full set's size in bytes. The
body is never affected. The JSON envelope has no such limit: ask for it, without
`Accept: application/octet-stream`, when you need every header.

Apigami failures, such as `no grant` or `extension offline`, still return non-2xx HTTP responses
with a JSON error body. Check Apigami's status before reading the site's response.

```js title="Server-side JavaScript"
const response = await fetch('https://api.eu.apigami.com/call', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.APIGAMI_API_KEY}`,
    'Content-Type': 'application/json',
    Accept: 'application/octet-stream',
    'Accept-Encoding': 'gzip',
  },
  body: JSON.stringify({
    user_ref: process.env.APIGAMI_USER_REF,
    op: 'request.fetch',
    params: {
      domain: 'example.com',
      url: 'https://example.com/',
      responseType: 'binary',
    },
  }),
})
if (!response.ok) throw new Error((await response.json()).error)
const status = Number(response.headers.get('x-apigami-status'))
const headers = JSON.parse(response.headers.get('x-apigami-headers'))
const body = await response.text()
console.log({ status, headers, body })
```

Node's `fetch` and Python Requests decode gzip automatically; curl needs `--compressed`.
Do not decompress the body a second time. For files, use `response.arrayBuffer()` instead of
`response.text()`; both read the decoded body. For JSON from the site, parse the decoded text.

Gzip requires an install running **0.4.0 or later**. Bodies under 4 KB and already compressed
types, such as images and PDFs, can come back uncompressed; older installs also return plain bytes.
Always accept both. `Accept-Encoding` alone, a text/base64 request, or the default JSON envelope
does not activate this compression. Other operations and MCP tool results keep their existing formats.

### Sites that require an anti-CSRF header

Use `headersFromCookies` to set a header from a cookie inside the browser, without returning the
cookie value to your app. The header and cookie names depend on the site.

```json title="Example params for a site using XSRF-TOKEN"
{
  "domain": "example.com",
  "url": "https://example.com/api/me",
  "headersFromCookies": { "X-XSRF-TOKEN": "XSRF-TOKEN" }
}
```

The header gets the cookie's value with one pair of surrounding double quotes removed. A
`JSESSIONID` cookie holding `"ajax:123"` with `{ "csrf-token": "JSESSIONID" }` sends `csrf-token: ajax:123`;
a Django site's `csrftoken` cookie goes as it is, with `{ "X-CSRFToken": "csrftoken" }`. A cookie the
browser does not hold leaves the header unset. Apigami doesn't guess which header a site needs. Use
`session.getCookies` only when the value needs more than that, because it brings the cookie out of the
browser.

## `stream.subscribe` — listen to a site in real time

Ask the browser to hold a long-lived response open — a `text/event-stream` — and relay every
event to your [webhook endpoints](/docs/webhooks/), with the tab closed. The subscription is
stored by Apigami and survives a browser restart; it ends when you unsubscribe, when the user
withdraws the site, or after 30 days offline.

```json title="Example params"
{
  "domain": "example.com",
  "request": {
    "url": "https://example.com/realtime/connect",
    "headers": { "accept": "text/event-stream", "x-session": "{{uuid}}" },
    "headersFromCookies": { "x-csrf-token": "csrftoken" }
  },
  "heartbeat": {
    "request": { "url": "https://example.com/realtime/heartbeat", "method": "POST", "body": "{\"session\":\"{{uuid}}\"}" },
    "intervalMs": 60000
  },
  "idleTimeoutMs": 45000,
  "requireCookies": ["session"],
  "filter": ["messagesTopic"]
}
```

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `request` | required | The request to hold open: `url` (`https://`, on the domain), `method`, `headers`, `headersFromCookies`, `body` — the shape of `request.fetch` |
| `heartbeat` | | `{ request, intervalMs }`: a request the browser replays every `intervalMs` (5 s to 1 h) while the stream is open |
| `idleTimeoutMs` | | No byte for this long (5 s to 1 h) closes the stream, which reopens on its own |
| `requireCookies` | `[]` | Cookie names that must exist before the browser opens the stream. Missing, it waits and opens when they appear |
| `filter` | `[]` | Regular expressions in RE2 syntax (no backreferences or lookaround), at most 10 of 500 characters; an event is relayed only if one matches its `data` |

`{{uuid}}` in any url, header value or body becomes one fresh uuid per open, the same in the
request and its heartbeat.

</details>

Result: the subscription — `id`, `domain`, `state` (`open` \| `closed`), `seq`, `created_at`,
`opened_at`, `closed_at`, `last_event_at`, `last_close_reason`.

Then your endpoints receive `stream.opened` (`data.status`, and `data.headers` narrowed to
`content-type`), `stream.event` (`data.event` is the SSE event: `event`, `data`, `lastEventId`)
and `stream.closed` (`data.reason`). Every one carries
`stream_id` and a per-subscription `seq`; a gap means a missed delivery, so refetch. A browser-side
close after an attempt (`http`, `ended`, `error`, `idle`) carries `retry_in_ms` and is followed by
a new `stream.opened`; `cookie_missing` waits for the cookie; `unauthorized` (the browser lost the
site's permission) waits for the next change; `unsubscribed`, `revoked`, `unpaired` (the user
disconnected from your app) and `detached` (the browser left the user's account) are final;
`install_offline` is followed by `stream.opened` when the browser is back.

An app holds at most 10 subscriptions on one browser: the eleventh is a 409. A browser older
than 0.6.0 is a 409 too.

## `stream.unsubscribe` — stop listening

```json title="Example params"
{ "domain": "example.com", "id": "SUBSCRIPTION_ID" }
```

Result: `{ "removed": true }`. Works while the browser is offline: it will not see the
subscription again.

## `stream.list` — your subscriptions on this browser

```json title="Example params"
{ "domain": "example.com" }
```

Result: `{ "streams": [ … ] }`, the same rows as `stream.subscribe` returns.

## `tabs.list` — the open tabs of a site

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |

</details>

```json title="Result"
{
  "tabs": [
    {
      "id": 1042,
      "url": "https://www.example.com/inbox",
      "title": "Inbox",
      "active": true
    }
  ]
}
```

Only tabs on the domain (or its subdomains) are returned. An empty list is not an error.

## `tabs.open` — open a tab on a site

Opens a tab in the user's browser, in the background by default. The url must be **https and on
the approved domain** (or a subdomain of it), exactly like a `request.fetch` target: an approval on
one site never sends the user's browser somewhere else.

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `url` | required | An https url on that domain |
| `active` | `false` | Bring the tab to the front |

</details>

```json title="Result"
{
  "tabId": 1043,
  "url": "https://www.example.com/inbox",
  "status": "loading",
  "active": false
}
```

It answers once the navigation is committed, not once the page is loaded: the `tabId` is usable
right away by `capture.start`, so the page load itself can be captured; a `tabs.read` has to wait
for the page.

`invalid url: <url>`, and a url outside the domain is refused.

## `tabs.navigate` — reload a tab, or send it elsewhere on the site

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `tabId` | | A tab from `tabs.list`. Omitted: the first open tab on the domain |
| `url` | | An https url on that domain to go to. Omitted: the tab is reloaded |
| `force` | `false` | Leave a page with unsaved changes: accepts its `beforeunload`, for this navigation only. Apigami Dev Assistant only — see [Native dialogs](#native-dialogs) |

</details>

```json title="Result"
{
  "tabId": 1042,
  "url": "https://www.example.com/inbox",
  "status": "loading",
  "reloaded": false
}
```

Same gate as `tabs.open` on the url, and the tab must already be on the domain. Answers once the
navigation is committed, like `tabs.open`.

`no open tab for <domain>`, `invalid url: <url>`, a url outside the domain and a `tabId` that is
not on the domain are refused.

## `tabs.parse` — read a tab

Extracts content from an open tab, with a **named** extractor. Nothing arbitrary ever runs in the
page: the extractors are a fixed list.

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `tabId` | | A tab from `tabs.list`. Omitted: the first open tab on the domain |
| `extractor` | `html` | `html` — the full document, `text` — its visible text, `title` — the document title |

</details>

```json title="Result"
{
  "tabId": 1042,
  "extractor": "text",
  "result": "Inbox\n3 unread…"
}
```

`no open tab for <domain>` when there is none, and a `tabId` that is not on the domain is refused.

## `tabs.read` — read a tab by selector

Reads a part of an open tab, chosen by a **CSS selector**, in the shape asked for. Like
`tabs.parse`, nothing arbitrary runs in the page: the selector and the format are parameters of an
extractor that ships with the browser.

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `tabId` | | A tab from `tabs.list`. Omitted: the first open tab on the domain |
| `selector` | `body` | CSS selector of the part to read |
| `format` | `text` | `text` — `innerText`, `html` — `outerHTML` without scripts, styles and tracking attributes, `outline` — one line per node `ref@rev role "name" [href\|value]`, open dialogs and popups first, `markdown` — the readable content, navigation removed |
| `maxChars` | `4000` | Truncation, between 200 and 50 000 |

</details>

```json title="Result"
{
  "tabId": 1042,
  "url": "https://www.example.com/in/x/",
  "selector": "main",
  "format": "outline",
  "matches": 3,
  "overlays": 0,
  "rev": "1a2b3c4d",
  "content": "main@1a2b3c4d region \"Profile\"\n  #contact@1a2b3c4d link \"Contact info\" [/in/x/detail/contact-info/]",
  "totalChars": 9120,
  "truncated": true
}
```

Each `ref` of an `outline` is a CSS selector you can send back as `selector`. The extractor reads
the light DOM of the tab: no shadow DOM, no iframes.

Every `ref` of an `outline` ends in `@` followed by `rev`, the url it was read at. A `ref` is a CSS
selector with no notion of when it was made, so after a `history.pushState` the same positional chain
still resolves — to another row. Paste a dated ref into `tabs.click`, `tabs.type` or
`tabs.screenshot` and the install refuses the gesture instead of acting on it; there is nothing to
ask for, and a selector you wrote yourself has no date and is simply not guarded. An install older
than `0.12.0` cannot read the date, and `POST /call` answers `409` rather than acting anyway and
letting you believe you are guarded. `rev` was briefly a parameter of its own and is now a `400`:
the ref carries its date. It is the **url**, not the DOM: a re-render that keeps the url
keeps the same date, so a ref that still matches is not a promise the page has not moved on.

An `outline` that found open dialogs, popups or floating menus prints them first, under a
`[open overlays]` line, then the rest of the page under a `[page]` line, and `overlays` counts them.
A modal rendered through a portal is a last child of `<body>`, so document order would otherwise put
it last and `maxChars` would cut it first. Neither banner has the `ref role "name"` shape: code that
splits an outline line by line has to skip them, along with the `… ` lines that say what was folded
or left out. An older build sends no `overlays` and no banner.

`invalid selector: <selector>`, `no element matches <selector>`, `invalid format: <format>`,
`no open tab for <domain>`, and a `tabId` that is not on the domain is refused.

## `tabs.storage` — a tab's web storage

The keys a site keeps in `localStorage` and `sessionStorage` of an open tab, and — on demand — one
value.

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `tabId` | | The `tabId` `tabs.open` returned, or a tab from `tabs.list`. Omitted: the first open tab on the domain |
| `key` | | Read this one key whole instead of listing |
| `area` | | `local` or `session`. Omitted: both, when listing |

</details>

```json title="Result"
{
  "tabId": 1042,
  "url": "https://www.example.com/feed/",
  "local": [
    {
      "key": "app:config",
      "type": "object",
      "length": 1284,
      "preview": "{tracking, locale, theme}"
    }
  ],
  "session": []
}
```

`preview` is the first 40 characters of the value; for a JSON object, up to 8 of its field names;
for an array, its number of items.

With a `key`, `{ "tabId": 1042, "url": "…", "area": "local", "key": "theme", "value": "dark",
"length": 4 }`, cut at 50 000 characters with `truncated: true`. But a key whose name matches
`token`, `secret`, `auth`, `session`, `key`, `password` or `csrf` is **never** returned: you get
`{ "redacted": true, "length": 320, "preview": "<320 chars, not shown>" }` instead, and the listing
shows that same preview for it; a JSON object or array keeps its field names or item count. Use
`session.getCookies` when you really need a credential, so the hand-off is explicit.

`no such key: <key>`, `no open tab for <domain>`, and a `tabId` that is not on the domain is refused.
A tab just opened that has not reached the site yet answers `the tab opened on <domain> is still
loading`, a tab closed since answers `tab <id> was closed`, and any other id, whether this browser
never had it or it is on another site, answers `tab <id> is not on the granted domain <domain>`: pass
the `tabId` `tabs.open` returned.

## Gestures — click, type, photograph

Everything above reads what a site has **already** done. A journey that *starts* with a gesture —
picking a delivery slot, opening a drawer, moving to the next step — cannot be observed at all,
because nothing sets it off. The operations below are the ones that reach into the page.

They are different from the rest in three ways, and each one is deliberate.

**They need the Apigami Dev Assistant build.** A click that a site takes seriously is a real Chrome
input event, and photographing a background tab means reading its renderer: both are
`chrome.debugger`, which Chrome does not allow as an optional permission. So they live in the same
second install as the network capture, and a standard install refuses them:
`gestures require the Apigami Dev Assistant build`. **That is what keeps a gesture out of a deployed
application** — an application talks to its own user's browser, which runs the standard build. These
operations are for *building* an integration, not for running one.

**They still go through the same two gates as everything else.** Connected, then approved for the
domain. A gesture on a site the user never allowed opens the same consent window as a
`request.fetch` would.

**Two things they refuse, whoever asks.**

- `tabs.type` never types into a password, a one-time code, a CVV, a card number or an IBAN. The
  refusal happens *in the page*, before a character is dispatched, and it uses the same test that
  redacts those fields out of `tabs.read`: what Apigami refuses to read, it refuses to write. There
  is no parameter that turns it off. When a flow needs one, the user fills it themselves.
- `tabs.click` refuses a button whose accessible name says it pays, orders, subscribes or closes an
  account. **This one is a speed bump, not a barrier**, and it is worth knowing which: a site that
  labels its button "Continuer" walks straight through it. It is there because the realistic failure
  is an agent clicking "Valider ma commande" in good faith while exploring, and that cost does not
  come back.
- **Neither `tabs.click` nor `tabs.type` acts inside a payment form.** What counts as one is Chrome's
  own definition, `CreditCardFormCompleteness::kCompleteCreditCardForm`: a card number field and an
  expiry field in the same form, matched with Chromium's own patterns. A lone security-code box
  counts too, since that is a card already on file one click from being charged, and a gift-card
  field does not, which is the trap Chromium itself guards against. This is the refusal that catches
  the checkout of a merchant no list of domains will ever hold, because the risk is on the page and
  not in the hostname.

  **It is scoped to the form the gesture lands in, never to the page.** A checkout page still reads
  with `tabs.read`, still photographs with `tabs.screenshot`, and its other forms still take a click:
  a delivery option, a promo code, a tab. Refusing the whole page would take away the picture at the
  exact moment it matters most.

**And some sites take no gesture at all.** Before a click or a keystroke, the browser asks Apigami
what it thinks of the *host in the tab* — not the domain that was consented to, which is a wildcard:
an approval on `qonto.com` covers `app.qonto.com`, so only the browser knows where a gesture will
actually land. A site rated `block` refuses `tabs.click` and `tabs.type` for every application, with
`is a site Apigami does not act on`. Online banking is the case this exists for.

Three things follow, and they are worth knowing before you plan around them:

- **Reading is untouched.** `tabs.read`, `tabs.storage`, the network capture and `tabs.screenshot`
  all work on a blocked site. Refusing the photograph would take away the one form of reading that
  shows a layout, and give nothing back: `tabs.read` already returns what the page says.
- **It is not a list you can see or ship around.** The rating is asked for at the moment of the
  gesture and cached for an hour, so a correction takes effect everywhere within the hour with
  nothing to reinstall. When Apigami cannot be reached and no recent answer is held, the gesture is
  **refused**, not allowed: `could not be asked what it thinks of`. That is deliberate — the other
  way round, anything able to block one request would lift the whole rule.
- **A version cannot be published against one either.** If a version declares a blocked domain under
  `x-apigami.domains` as reached through the browser, publishing it is refused rather than queued.

The lists come from the [Université Toulouse Capitole
blacklists](https://dsi.ut-capitole.fr/blacklists/), used under CC BY-SA 4.0: retail banking,
finance, crypto exchanges and betting, 46 575 domains refreshed daily. They are not exhaustive —
plenty of neobanks are in none of them — so if a site is rated wrongly in either direction, write to
[contact@apigami.com](mailto:contact@apigami.com):
it is one row to change, and it takes effect within the hour.

A gesture **attaches the network capture to the tab before acting**, so the request it provokes is
recorded by construction — that is the whole point of making one.

### `tabs.click` — click an element

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `tabId` | | A tab from `tabs.list`. Omitted: the first open tab on the domain |
| `selector` | required | CSS selector. Must match **exactly one** element |
| `button` | `left` | `left`, `right` or `middle` |
| `clickCount` | `1` | 2 for a double click, 3 for a triple |

</details>

`selector` is a plain CSS selector, and a `ref` from a `tabs.read` `format: "outline"` **is** one:
read the page, then aim with what you read. There is no second selection language.

A selector matching twelve elements is refused rather than resolved to the first — clicking one of
twelve is a wrong action taken in silence.

```json title="Result"
{
  "tabId": 1043,
  "url": "https://www.example.com/livraison/creneaux",
  "selector": "#slot-2",
  "role": "button",
  "name": "Jeudi 8h-10h",
  "x": 190,
  "y": 220,
  "button": "left",
  "clickCount": 1
}
```

`role` and `name` are what the element says it is, echoed back so you can tell "I clicked the button
I meant" from "I clicked a button". `url` is read *after* the click, so a click that navigated says
so here.

`<n> elements match <selector>`, `<selector> is covered at that point by <what>`,
`<selector> is not rendered: it has no size`, `<selector> could not be scrolled into view`,
`<selector> says "<name>", which commits the user`, `<selector> is inside a payment form`,
`<selector> is a select: use tabs.select …`,
`invalid selector: <selector>`,
`the page has changed since that outline (rev <a>, now <b>)`,
`no element matches <selector>`, `no open tab for <domain>`.

A selector refusal — `<n> elements match` or `no element matches` — carries more than one line when
the page has something to offer: `Try one of these instead (<n>):`, then at most five lines
`<selector> — <role> "<name>"`. Each is a CSS selector resolving to exactly one element, which saves
a `tabs.read` to recover from a stale ref. Match on the first line alone.

### `tabs.type` — type into a field

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `tabId` | | A tab from `tabs.list`. Omitted: the first open tab on the domain |
| `selector` | required | CSS selector of the field. Must match **exactly one** element |
| `text` | required | What to type. Never a credential |
| `clear` | `false` | Select what is there first, so the text replaces it |
| `submit` | `false` | Send a real Enter afterwards |

</details>

The field is focused with a real click, then the text is inserted **in one go** — which fires
`input`, the event frameworks listen to, but not one `keydown` per character. A site that only reacts
to a key needs `submit: true`, and that one is a real Enter.

```json title="Result"
{
  "tabId": 1043,
  "url": "https://www.example.com/livraison/",
  "selector": "#postcode",
  "role": "textbox",
  "name": "Code postal",
  "chars": 5,
  "cleared": false,
  "submitted": false
}
```

The text you sent is **never echoed back**: it is the user's, and `chars` answers the only question
you have about it.

`<selector> is a credential or payment field`, `<selector> is inside a payment form`,
`<selector> is not a text field: it is a <role>`, `<n> elements match <selector>`, `<selector> is covered at that point by <what>`,
`the page has changed since that outline (rev <a>, now <b>)`,
`invalid selector: <selector>`, `no element matches <selector>`, `no open tab for <domain>`.

A selector refusal — `<n> elements match` or `no element matches` — carries more than one line when
the page has something to offer: `Try one of these instead (<n>):`, then at most five lines
`<selector> — <role> "<name>"`. Each is a CSS selector resolving to exactly one element, which saves
a `tabs.read` to recover from a stale ref. Match on the first line alone.

### `tabs.key` — send one key

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `tabId` | | A tab from `tabs.list`. Omitted: the first open tab on the domain |
| `selector` | | Where the key goes, focused by a real click first. Omitted: whatever holds the focus |
| `key` | required | `Enter`, `Tab`, `Escape`, `Backspace`, `Delete`, `Home`, `End`, `PageUp`, `PageDown`, the four arrows, or one printable character |
| `modifiers` | `[]` | `alt`, `ctrl`, `meta`, `shift`, held while it is sent. Anything but `shift` only goes with a latin letter, a digit or a named key |
| `repeat` | `1` | Up to ten |

</details>

The list of names is **closed**, because `Input.dispatchKeyEvent` needs a virtual key code per key
and an approximate one does not fail: it does nothing, silently. A printable character requires a
`selector` — without one it would land in whatever holds the focus, which is exactly what the
credential refusal exists to prevent.

With a `selector`, the focus is taken with a **real click** — the one `tabs.click` makes — so the two
refusals of `tabs.click` are this operation's as well: a `<select>` (use `tabs.select`) and a button
whose accessible name says it pays, orders or closes an account. A named key through one of those
would be the same act as clicking it.

**Browser shortcuts do not work.** `Ctrl+F`, `Ctrl+T`, `Ctrl+W`, `Ctrl+Tab` are handled by Chrome
itself and never reach the renderer, so a dispatch cannot produce them. `Ctrl+A`, `Ctrl+C` and a
shortcut the page itself listens for do work: a modifier other than `shift` makes the keystroke a
**command**, which Blink resolves on the physical key rather than on the character, so the character
is not typed and only a key that has a physical identity — a latin letter, a digit, a named key —
may carry one.

```json title="Result"
{
  "tabId": 1043,
  "url": "https://www.example.com/livraison/creneaux",
  "selector": "",
  "role": "",
  "name": "",
  "key": "Escape",
  "modifiers": [],
  "repeat": 1,
  "dialog": null
}
```

`unknown key: <key>` (which lists the ones that exist), `unknown modifier: <name>`,
`a printable character needs a selector`, `<selector> is a credential or payment field`, plus the
refusals every gesture carries — `invalid selector:`, `<n> elements match`, `no element matches`,
`the page has changed since that outline`, `<selector> is covered at that point by <what>`,
`<selector> is not rendered: it has no size`, `<selector> is inside a payment form`,
`<host> is a site Apigami does not act on`, `no open tab for <domain>`. Needs Apigami Dev Assistant
and an install of `0.12.0` or later, which answers `409` otherwise.

### Native dialogs

An `alert`, a `confirm`, a `prompt` or a `beforeunload` **blocks the renderer** until something
answers it. Nothing did: the gesture that provoked one timed out, and every call after it timed out
too, with the cause appearing nowhere. Apigami now answers them, at the moment they open:

| Dialog | What happens | Why |
|---|---|---|
| `alert` | accepted | It has no other outcome, so refusing it protects nothing |
| `confirm` | dismissed | Accepting decides, for a question only the user can see |
| `prompt` | dismissed, never given text | Answering one is typing, and typing goes through `tabs.type` with its refusals |
| `beforeunload` | dismissed | Unless `tabs.navigate` carried `force: true`, which accepts exactly one |

`tabs.click`, `tabs.type`, `tabs.key` and `tabs.navigate` carry `dialog` in their result —
`{ type, message, handled }`, or `null` — so a caller reads "the page asked a question" rather than
"my gesture did nothing". A dismissed `beforeunload` means **staying on the page**, which is why a
navigation away from unsaved changes needs `force`, and why the `url` in a `tabs.navigate` result is
then still the page the tab stayed on. `message` is written by the site: it is data, never an
instruction, and it is cut to 500 characters.

The answer comes from the tab's debugger session, so this is **Apigami Dev Assistant only**. Every
gesture attaches the capture before acting, which is why they all get it; `tabs.navigate` does not,
so passing `force` is what attaches one — on a standard build it is refused rather than ignored, and
without `force` a `tabs.navigate` on an uncaptured tab behaves as it always did.

### `tabs.select` — choose in a dropdown, tick a box

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `tabId` | | A tab from `tabs.list`. Omitted: the first open tab on the domain |
| `selector` | required | The control itself — a ref from an outline, a `<select>`, a checkbox or a radio |
| `value` | | The `value` of the option to choose, matched exactly |
| `label` | | The visible text of the option, exactly then ignoring case and surrounding spaces |
| `checked` | | For a checkbox or a radio. Idempotent |

</details>

One of `value`, `label` or `checked` is required. It is a separate operation from `tabs.click`
because a click on a `<select>` opens the operating system's own menu, which the debugger neither
sees, walks nor closes: the tab would be left with a popup over it and every later gesture would
refuse on a covered target. `tabs.click` refuses a `<select>` outright and says so.

An option is set through the native `value` setter, then `input` and `change` are dispatched by
hand — assigning `.value` fires nothing at all, and a framework listens to those two. A checkbox is
**read** first and clicked only if it is not already in the state asked for, so `changed: false` is
what a replayed call gets rather than a second click undoing the first.

```json title="Result"
{
  "tabId": 1043,
  "url": "https://www.example.com/livraison/creneaux",
  "selector": "#slot@1a2b3c4d",
  "role": "combobox",
  "name": "Créneau de livraison",
  "value": "thu-pm",
  "label": "Jeudi 14h-16h",
  "checked": null,
  "changed": true
}
```

`no option of <selector> has <what>` and `<n> options of <selector> have <what>` both **list the
options**, up to twenty: an outline never shows them, so a re-read would not have helped.
`<selector> is a select: pass \`value\` or \`label\``, `<selector> is a <type>: pass \`checked\``,
`<selector> is not a select, a checkbox or a radio`, `<selector> is disabled`,
`<selector> accepts several options at once`, `<selector> is a radio, and a radio is never unticked
on its own`, plus the refusals every gesture carries — `invalid selector: <selector>`,
`<n> elements match`, `no element matches`, `the page has changed since that outline`,
`<selector> is covered at that point by <what>`, `<selector> is not rendered: it has no size`,
`<selector> could not be scrolled into view`, `<selector> is a credential or payment field`,
`<selector> is inside a payment form` (an expiry month is a dropdown in exactly that form),
`<host> is a site Apigami does not act on`, `no open tab for <domain>`. Needs Apigami Dev Assistant and an install of `0.12.0` or later, which
answers `409` otherwise.

### `tabs.scroll` — move a page, or one panel in it

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `tabId` | | A tab from `tabs.list`. Omitted: the first open tab on the domain |
| `selector` | | Something **inside** the panel to move — a ref from a `tabs.read` outline. Omitted: the window |
| `direction` | `down` | `up`, `down`, `top` or `bottom` |
| `amount` | `500` | CSS pixels, capped at three screens of the container per call |

</details>

A real page stacks several scrollable boxes and only the page knows which of them moves, so
`selector` names something the outline showed and the browser walks **up** to whichever ancestor
scrolls **and can still move in the direction asked for** — an ancestor already at its limit is
skipped, so scroll chaining is decided here rather than left to the browser. It resolves, moves and
measures in one pass.

```json title="Result"
{
  "tabId": 1043,
  "url": "https://www.example.com/livraison/creneaux",
  "selector": "#slot-2",
  "container": "div#drawer",
  "direction": "down",
  "before": 0,
  "after": 500,
  "moved": 500,
  "movedContainer": true,
  "movedWindow": false,
  "scrollable": true,
  "atStart": false,
  "atEnd": false
}
```

**Three states, and telling them apart is the point.** `scrollable: false` means what was named
cannot scroll at all — an app-shell page answers this when no `selector` was given, and it is not
the end of anything. `moved: 0` with room left means the content is still arriving, so wait rather
than scroll harder. `atEnd: true` means the end of what is **loaded**; a lazy feed may have more
once it arrives. `movedContainer` and `movedWindow` say which of the two actually moved: the window
is only ever touched when no container was retained.

`invalid selector: <selector>`, `<n> elements match <selector>`, `no element matches <selector>`,
`invalid direction: <direction>`, `no open tab for <domain>`,
`<host> is a site Apigami does not act on`. Needs Apigami Dev Assistant and an install of `0.11.0`
or later, which answers `409` otherwise.

### `tabs.settle` — wait for a page to stop changing

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `tabId` | | A tab from `tabs.list`. Omitted: the first open tab on the domain |
| `quietMs` | `500` | How long the page must stay still to count as settled, 50 to 3000 |
| `timeout` | `5000` | Give up waiting after this, 100 to 20 000 |
| `checkNetwork` | `true` | Also wait for the requests the page started |

</details>

Instead of guessing a delay, wait for a criterion. It returns once the page has gone `quietMs`
without touching its DOM and, unless `checkNetwork` is false, once nothing it asked for is still in
flight — **Apigami knows that from the capture**, which already holds one row per request waiting
for its `loadingFinished`, so nothing is injected into the page to measure it. The DOM half is one
`MutationObserver`, posted and withdrawn.

```json title="Result"
{
  "tabId": 1043,
  "url": "https://www.example.com/livraison/creneaux",
  "settled": true,
  "waitedMs": 740,
  "reason": "quiet",
  "mutations": 12,
  "requests": 0
}
```

**A timeout is not an error.** `settled: false` with `reason: "timeout"` means the page never
stopped, which a ticker, a live feed or a poller does. That is an answer about the page; refusing
the call would hide it behind a retry. `requests` then says how many were still in flight, which is
what tells a caller to pass `checkNetwork: false`.

Every gesture already waits 300 ms of its own. This is what to add when 300 ms is not enough, and
it deliberately is not a parameter of `tabs.click` or `tabs.type`: a wait hidden inside a click is a
wait nobody can tune. Over MCP there is no separate tool for it — `page_read` takes `settle: true`,
because every answer of a bare wait ended in « now read it ».

`no open tab for <domain>`. Needs Apigami Dev Assistant and an install of `0.11.0` or later, which
answers `409` otherwise.

### `tabs.screenshot` — photograph a tab

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `tabId` | | A tab from `tabs.list`. Omitted: the first open tab on the domain |
| `selector` | | Clip to this element. Omitted: the visible viewport |
| `format` | `jpeg` | `jpeg` or `png` |
| `quality` | `70` | jpeg only, 25 to 100. Lowered automatically if the image would be too large |
| `maxWidth` | `1200` | Longest width in **CSS** pixels, up to 1568 |
| `foreground` | `false` | Bring the tab forward for the capture, then put back the one the user was on. Install 0.14.0 |

</details>

It photographs **the tab in front, and only that one**. Chrome draws the tab the user is looking at
and no other, so `Page.captureScreenshot` on a tab sitting behind waits for a frame that never comes
— measured on Chrome 153, and neither focus emulation nor `captureBeyondViewport` changes it. The
operation gives up after 2.5 s and says the tab is not being drawn, rather than spending the whole
call's budget on a picture that does not exist. `tabs.read` has no such limit: it reads a background
tab as outline, markdown, text or html, and it is what to reach for when the tab cannot come forward.

`foreground: true` (install **0.14.0**, 409 `install too old for bringing a tab forward to photograph
it` below it) is the way through when the layout really is the question: the tab is activated, the
capture taken, and the tab the user was on is put back in a `finally` — including when the capture
fails. It costs the user their screen for about a second, so it is a parameter a caller passes
deliberately and never a default. A tab whose whole WINDOW is minimised or on another desktop still
photographs nothing, and the refusal says so rather than sending the caller round again.

The user sees nothing new when the tab IS in front — Chrome's "Apigami Dev Assistant started
debugging this browser" bar is already up, because the capture is attached.

```json title="Result"
{
  "tabId": 1043,
  "url": "https://www.example.com/livraison/creneaux",
  "selector": "",
  "format": "jpeg",
  "quality": 70,
  "width": 1200,
  "height": 750,
  "bytes": 184320,
  "data": "/9j/4AAQSkZJRgABAQ…"
}
```

`data` is base64. **Read `tabs.read` with `format: "outline"` first**: it describes the same page in
a fraction of the size and gives you refs you can act on, where an image gives you pixels you
cannot. A screenshot earns its place when the layout *is* the question — a slot grid, a seating
plan, a map, a canvas.

Sizes are in **CSS pixels, never device pixels**, so a high-density screen costs nothing extra. An
image costs a model roughly `width x height / 750` tokens to read: 1200x750 is about 1 200. A clip
over three viewport heights is cut to three, and an image that would still be too large steps its
jpeg quality down rather than coming back unusable.

`the image is too large as png: ask for jpeg, or a selector that clips it`,
`the image is too large even at the lowest quality: pass a selector that clips it`,
`the page has changed since that outline (rev <a>, now <b>)`,
`<n> elements match <selector>`, `no element matches <selector>`, `no open tab for <domain>`.

A selector refusal — `<n> elements match` or `no element matches` — carries more than one line when
the page has something to offer: `Try one of these instead (<n>):`, then at most five lines
`<selector> — <role> "<name>"`. Each is a CSS selector resolving to exactly one element, which saves
a `tabs.read` to recover from a stale ref. Match on the first line alone.

## `session.getCookies` — hand off the session

Returns the site's cookies, `httpOnly` cookies included — every cookie the browser holds for the
domain. Use it when a proxy is not enough: to run the session from your own infrastructure, or to build
a header from a cookie in a way `headersFromCookies` cannot. A header that is just a cookie's value needs
no hand-off: `headersFromCookies` fills it inside the browser
([Sites that require an anti-CSRF header](#sites-that-require-an-anti-csrf-header)). Beware that many sites tie a session to the device it was created on;
a copied session may not work from a different device.

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `domain` | required | The approved site |
| `names` | | Return only these cookies. Omitted: the whole jar |

</details>

```json title="Result"
{
  "cookies": [
    {
      "name": "sid",
      "value": "…",
      "domain": ".example.com",
      "path": "/",
      "secure": true,
      "httpOnly": true,
      "sameSite": "lax",
      "expirationDate": 1790000000
    }
  ]
}
```

Name what you need whenever you can: reading one cookie is a smaller ask than taking the session,
and the filtering happens in the browser, so the rest never leaves it. A name the browser holds no
cookie for is simply absent from the answer.

The fields are the browser's own cookie object: `name`, `value`, `domain`, `path`, `secure`,
`httpOnly`, `sameSite`, `expirationDate`.

## `session.refresh`

Same as `session.getCookies`, read again. Kept as a separate name so a refresh loop reads as one.

## `emails.send` — email the user

Write to the user in your application's name, without ever receiving their address. Apigami answers it
itself, never a browser: no `domain`, no approval window, and the email arrives whether or not a browser
of the user is online. Your manifest declares `"permissions": ["emails"]`, and the user is connected to
your application; a `marketing` email also needs them to have ticked your news box.

```json title="Example params"
{
  "kind": "transactional",
  "subject": "Your order is ready",
  "blocks": [
    { "type": "text", "text": "Your order is ready to collect." },
    { "type": "button", "label": "See the order", "url": "https://example.com/orders/42" }
  ]
}
```

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `kind` | required | `transactional`, about the user's own use of your application, or `marketing`, your news, for a user who ticked your news box |
| `subject` | required | One line, 150 characters at most |
| `blocks` | required | 1 to 50 of `hero`, `heading`, `text`, `item`, `callout`, `button`, `image`, `divider`: [the blocks](/docs/emails/#the-blocks) |
| `to` | optional | `"team"`: every member of your application's team instead of a user, for a `transactional` email, with no `user_ref`, from any credential: [tell your team](/docs/emails/#tell-your-team-a-public-form) |

No field names an address or carries HTML. With an API key or `APIGAMI_KEY`, `user_ref` names the
recipient; under a run token, the run's person is the recipient and `user_ref` is refused.

</details>

```json title="Result"
{ "email_id": "4b1e…", "status": "sent" }
```

A refusal sends nothing and says why: news to a user who did not tick your news box gets `403 marketing
not accepted by this user`, a user who received 5 emails in 24 hours `429 emails rate limited`, and news
to one who received yours in the last 24 hours `429 news already sent to this user today`. Every refusal, the blocks, the limits and the MCP tools are in
[Emails to your users](/docs/emails/).

## `ai.generate` — ask a model

Have a model read text, PDFs and images, and answer in text or in JSON following your schema. Apigami
answers it itself, in the EU, never a browser: no `domain`, no `user_ref`, no approval window. Your
manifest declares `"permissions": ["ai"]`; the call comes from your server, your hosted code or an
action, never from a page.

```json title="Example params"
{
  "instructions": "Extract the person's name. Use null when the text does not say.",
  "input": [{ "text": "Jean Dupont, developer, lives in Lyon." }],
  "output_schema": { "type": "object", "properties": { "name": { "type": ["string", "null"] } }, "required": ["name"] }
}
```

<details>
<summary>Parameters</summary>

| Param | | |
|---|---|---|
| `instructions` | required | What the model must do, 1 to 32 000 characters |
| `input` | required | 1 to 10 parts: `{ "text" }`, or `{ "file": { "media_type", "data_base64" } }` for a PDF, plain text or a PNG, JPEG or WebP image, 14 MB of files at most |
| `output_schema` | optional | A JSON Schema of the answer, 16 kB at most: [the keywords it may use](/docs/ai/#structured-output) |
| `max_output_tokens` | optional | 1 to 16 384, 4 096 by default |

</details>

```json title="Result"
{ "output": { "name": "Jean Dupont" }, "finish_reason": "stop", "usage": { "input_tokens": 188, "output_tokens": 43 } }
```

A refusal says why and what to do: a version that does not declare the permission gets `403 ai not
permitted for this app: …`, a Word file `415 unsupported media type …`, a busy model `503` with
`Retry-After`. Every refusal, the limits and what is kept are in [AI in your application](/docs/ai/).