Browser operations
MarkdownChoose 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.
{ "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.
{ "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:
{ "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
Section titled “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:
{ "domain": "example.com", "url": "https://example.com/uploads/abc", "method": "PUT", "headers": { "content-type": "application/pdf" }, "body": "JVBERi0xLjcKJc…", "bodyEncoding": "base64"}Gzip and binary responses
Section titled “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-streamon the outerPOST /call.Accept-Encoding: gzipon 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.
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.
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
Section titled “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.
{ "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.
{ "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.
stream.unsubscribe — stop listening
Section titled “stream.unsubscribe — stop listening”{ "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”{ "domain": "example.com" }Result: { "streams": [ … ] }, the same rows as stream.subscribe returns.
tabs.list — the open tabs of a site
Section titled “tabs.list — the open tabs of a site”Parameters
| Param | ||
|---|---|---|
domain |
required | The approved site |
{ "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
Section titled “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.
Parameters
| Param | ||
|---|---|---|
domain |
required | The approved site |
url |
required | An https url on that domain |
active |
false |
Bring the tab to the front |
{ "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 |
{ "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
Section titled “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.
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 |
{ "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
Section titled “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.
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 |
{ "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
Section titled “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.
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 |
{ "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
Section titled “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.typenever 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 oftabs.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.clickrefuses 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.clicknortabs.typeacts 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 withtabs.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 andtabs.screenshotall 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.readalready 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.domainsas 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.
tabs.click — click an element
Section titled “tabs.click — click an element”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.
{ "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
Section titled “tabs.type — type into a field”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.
{ "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
Section titled “tabs.key — send one key”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.
{ "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
Section titled “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
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.
{ "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`, , , , , , plus the refusals every gesture carries — invalid selector: , , no element matches, the page has changed since that outline, , , , , (an expiry month is a dropdown in exactly that form),, 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.
{ "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.
{ "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
Section titled “tabs.screenshot — photograph a tab”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.
{ "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 |
{ "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
Section titled “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
Section titled “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.
{ "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.
{ "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.
ai.generate — ask a model
Section titled “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.
{ "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 |
{ "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.