Skip to content

Browser operations

Markdown

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 before exploring the options below.

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

user_ref comes from connecting the user. 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.

How the browser is chosen
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.

By default, the API wraps each result below in { "result": ... }. request.fetch also supports raw responses with gzip. 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

Section titled “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.

Example params
{
"domain": "example.com",
"url": "https://example.com/",
"method": "GET"
}
Parameters
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

Result:

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.

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:

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

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.

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.

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.

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.

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

Section titled “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, 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.

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"]
}
Parameters
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.

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.

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

Section titled “stream.list — your subscriptions on this browser”
Example params
{ "domain": "example.com" }

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

Parameters
Param
domain required The approved site
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.

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.

Parameters
Param
domain required The approved site
url required An https url on that domain
active false Bring the tab to the front
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

Section titled “tabs.navigate — reload a tab, or send it elsewhere on the site”
Parameters
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
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.

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

Parameters
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
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.

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.

Parameters
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
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.

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

Parameters
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
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.

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, 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: 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.

Parameters
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

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.

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.

Parameters
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

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.

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.

Parameters
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

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.

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.

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

Section titled “tabs.select — choose in a dropdown, tick a box”
Parameters
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

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.

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`, is a : pass `checked`, is not a select, a checkbox or a radio, is disabled, accepts several options at once, is a radio, and a radio is never unticked on its own, plus the refusals every gesture carries — invalid selector: , elements match, no element matches, the page has changed since that outline, is covered at that point by , is not rendered: it has no size, could not be scrolled into view, is a credential or payment field, is inside a payment form(an expiry month is a dropdown in exactly that form), is a site Apigami does not act on, no open tab for . Needs Apigami Dev Assistant and an install of 0.12.0or later, which answers409` otherwise.

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

Section titled “tabs.scroll — move a page, or one panel in it”
Parameters
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

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.

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

Section titled “tabs.settle — wait for a page to stop changing”
Parameters
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

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.

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.

Parameters
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

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.

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

Section titled “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). Beware that many sites tie a session to the device it was created on; a copied session may not work from a different device.

Parameters
Param
domain required The approved site
names Return only these cookies. Omitted: the whole jar
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.

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

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.

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" }
]
}
Parameters
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
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

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.

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.

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.

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"] }
}
Parameters
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
max_output_tokens optional 1 to 16 384, 4 096 by default
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.