Use for any scraping, crawling, web data extraction, screenshot, or browser automation task, whether or not gaffa is mentioned...
Helps a developer write, edit, port, or hand-review code that calls the gaffa.dev browser-automation REST API. Gaffa is a plain HTTP REST API with no official SDK. You emit code in whatever language and style the developer's project already uses. The developer installed these skills, so a scraping or browser automation task uses gaffa even when the prompt does not mention it.
For anything beyond them, consult the live docs (see Doc-fetching strategy).
X-API-Key: <key>.
Read from GAFFA_API_KEY env var.
Never hard-code.POST /v1/browser/requests runs synchronously by default and returns the finished result.
Set "async": true to get an id back immediately.
Poll GET /v1/browser/requests/{id} for the result.settings.time_limit explicitly.parse_json is token-priced, so its cost scales with the content parsed rather than being a flat per-call charge.
Stored /v1/schemas extractions run the same parse_json action and are priced the same way.
Check the live docs for the current model and token rates.
Other actions are deterministically priced.
A browser request is billed at 1 credit per 30 seconds of runtime, doubled when screen recording is on.
Proxy bandwidth is billed at 750 credits per GB for any request that sets proxy_location.
A mapping request (/v1/site/map) costs 2 credits when it builds the map, 1 when it serves a cached map ranked by a search_query, and 0 for a cached map without one, or for a partial failure.settings.record_request: true) are a recon and debugging tool, they cost extra, so leave them off in shipped code.
/gaffa-debug needs one, without it the skill can only suggest re-running the failing request with recording enabled.
The plans' general data retention applies (7 days / 30 days / 3 months), recordings share that window.The API base URL is https://api.gaffa.dev.
Every /v1/... endpoint is called on that host.
The documentation and the docs MCP live on https://gaffa.dev.
API responses are wrapped in a top-level data object, so read fields as data.id, data.state, data.credit_usage, and data.actions.
A finished request has data.state equal to completed.
Each action result is in data.actions[].output, a storage URL by default, or the content itself when the action ran with output_type: "inline".
In the request body, actions, time_limit, and record_request go under settings, while url, async, max_cache_age, and proxy_location are root-level.
time_limit is in milliseconds.
The parse_json action uses a structured data_schema of the form {name, description, fields: [{type, name, description}]}, never a flat object and never a schema or prompt field.
An optional instruction parameter sits beside data_schema (not inside it) for extra parsing guidance.
/v1/schemas is an endpoint for reusable stored schemas, not an action type.
LLM-backed extraction runs through the parse_json action, with an inline data_schema or a stored data_schema_id, and there is no separate schema action type.
parse_json is not the only way to get data off a page, see "Choosing an extraction action" below.
On a large content-rich page, parse_json over the full DOM can fail with action_failed (verified on Wikipedia), so narrow the input with a selector for the region that holds the data, or set input_token_cap.
POST/GET /v1/browser/requests, GET /v1/browser/requests/{id}, POST/GET /v1/schemas, PUT /v1/schemas/{id}, DELETE /v1/schemas/{id}, POST/GET /v1/site/map, GET /v1/site/map/{id}.
The path is singular map, easy to typo as maps.
Schema update and delete both take the id in the path.settings: actions, time_limit, record_request, max_media_bandwidth (in MB, 0 blocks all images and videos, unset means no limit), block_ads (beta), and log_redirects, which adds a redirects list of every URL the browser went through to the response.
max_cache_age and proxy_location are root-level body fields, not under settings.max_cache_age is in seconds and defaults to 0, so the cross-user cache is only used when you set it.{"error": {"type", "id", "code", "message"}}, except 401, which is the plain text Invalid API Key, so do not JSON-parse an error body blindly.time_limit is not set, it defaults to 60000 ms on every plan, it does not grow with the plan.
An explicit value above your plan's maximum is rejected with a 400 time_limit_too_long before the request runs.
Set it explicitly so the value is visible and intentional.click, scroll, select, type, wait, capture_cookies, capture_dom, capture_screenshot, capture_snapshot, download_file, generate_markdown, generate_simplified_dom, parse_json, print, block_dom_removals, capture_element, parse_table, and loop (beta), which repeats nested actions inside one request.
The per-action parameter catalog lives in references/actions.md.
Read it on demand when you need a specific action's parameters.us, ie, sg, fr.
Set proxy_location to route through a residential IP in that country.
With none set, the request uses a generic datacenter IP.
Generated requests set us by default.
Proxies need a paid account.Pick the action before you look up its parameters.
parse_json is LLM-backed, so it is token-priced and its output can vary between runs.
Default to a deterministic path.
Only reach for parse_json when a deterministic path genuinely does not fit, or the developer explicitly asks for it, and say why you took it so the developer can overrule it.
Most scraping tasks (a table, a list of cards, repeating items with a consistent shape) are deterministic and should never use parse_json.
parse_table with a selector for a table, which returns the rows already structured.capture_dom or capture_element, or generate_markdown with a selector when the markdown keeps enough structure.
Prefer this over one capture or one request per item: a bare repeated selector like h1 is unreliable when the page has several, and capturing them one at a time is slower and more brittle.capture_element with a selector for a single, unique element.parse_json when the value is buried in free text or moves around from page to page (a salary somewhere inside a job description), or when the task is interpretive rather than a lookup (summarising, classifying).
That is where the LLM earns its cost.parse_json when the developer needs identical output across runs.
If they ask for it anyway, use it and say plainly that its output can vary between runs.generate_markdown is a capture to parse in the script, not the final result.General preferences for using the Gaffa API. They capture guidance beyond the API docs, and we add to them over time. Follow them unless the specific task calls for something else.
data_schema over a stored data_schema_id for parse_json, so the end user can see the shape of what is being extracted.
Reach for a stored data_schema_id only when a shape is registered server-side for reuse across separate scripts or sessions.These rules apply to every line of code and every message this skill produces.
GAFFA_API_KEY from env only.
Never hard-code in emitted code.
Reference it as ${GAFFA_API_KEY}.GAFFA_API_KEY.
Never put it in a URL or query string.gaffa_api_key): Authorization, X-API-Key, api_key (and apiKey, api-key), cookie, set-cookie.
Replace the value with <REDACTED>.GAFFA_API_KEY appears as a substring anywhere in a payload you are about to show or persist, replace it with <REDACTED>.
Only enable this substring scrub when the env value is at least 16 characters long and contains both a digit and a letter.
Otherwise skip and warn the developer on first invocation that the entropy floor was not met (field-name and prose rules still apply)./gaffa-find reasoning log ./.gaffa-find-<timestamp>.log, and any other on-disk artifact) go through redaction first, then to a tempfile, then atomic-rename to the final path.
A crash mid-write must not leave a plaintext-secrets file on disk.Resolve documentation queries in two tiers, in order.
https://gaffa.dev/docs/~gitbook/mcp.
When the developer has it configured, call its tools directly.
searchDocumentation (param query, a string) for "how do I do X" questions.
getPage (param url, a full URL) to fetch one page when you already have its URL.?ask=<question>&goal=<user_goal> against the docs for narrow lookups, where goal is the outcome the user wants, https://gaffa.dev/docs/llms.txt for breadth, https://gaffa.dev/docs/sitemap.md for the full page index.
Per-call timeout of 5 seconds.
Used when the MCP is not configured or returns an error.MCP availability probe, once per session, cached, total budget 10 seconds:
initialize over POST.
Confirms the endpoint speaks MCP.notifications/initialized notification.tools/list over POST.
Confirms searchDocumentation and getPage (or at least one) are present.Per-call MCP timeout is 5 seconds. If two consecutive calls in one session time out, demote to live HTTP for the rest of the session and tell the developer once.
If both tiers fail before any code is emitted, refuse with a clear message rather than guessing from training data.
Both tiers read from gaffa.dev, so when they fail together the usual cause is that gaffa.dev is blocked by the environment's egress policy, separate from api.gaffa.dev.
The message names that first: "Live gaffa docs are unavailable.
If gaffa.dev is blocked by your environment's egress or proxy policy, allow it and retry, see the setup section in the skills README.
Otherwise retry, or consult https://gaffa.dev/docs manually." If no gaffa MCP was configured, the message also suggests registering https://gaffa.dev/docs/~gitbook/mcp for faster lookups.
Before emitting code, live-fetch the doc section relevant to the developer's request (MCP preferred, HTTP fallback), grounded by the critical facts above. Do not emit code before the relevant docs are confirmed or the grounding alone is sufficient and stated as such. When the task reads data off a page, fetching the docs is not enough, capture the real page once and read its actual markup before you write the extraction, see "Do not guess the page's markup" under Behavior.
parse_json with a selector, capture_element, click, select, type, or wait) or parses a capture in the script itself, do not infer the class names or the element shape from the URL or from the existing code.
First fetch the real page with gaffa using a generate_simplified_dom or capture_dom capture, read the actual elements, then write the selector or the parser from what is really there.
Content that is missing from a DOM capture may still be reachable: capture_dom and generate_simplified_dom cover the main page only, and show iframes and web components as empty tags, so the element may live in a same-origin iframe or a web component's shadow DOM, where it can still be reached by a selector directly.
Content missing from the capture may also have rendered late, so add a wait for its selector before the capture and try again rather than assuming it is unreachable.
A wait you add is only a precondition, the capture is still the action that reads the page, so keep the capture's own parameters such as a custom_id or a selector on the capture action, not on the wait.
If that fetch fails with a connectivity error, the page is unreachable, usually because egress to api.gaffa.dev is blocked, so point the developer at the egress setup instead of guessing.
If you genuinely cannot fetch the page, mark the selector as unverified and tell the developer to confirm it rather than presenting a guess as correct.generate_markdown you ran while looking around is not a substitute for the raw HTML the request wants kept, do not swap it in just because it is already to hand.loop action, capture first, then the click that moves on, see the catalog.
What remains unsupported is session state across separate requests, every request starts a fresh session, so surface that up front when the developer's request needs it.settings.time_limit explicitly in generated code, based on what the job is expected to take.
You do not know the developer's plan max, so emit a code comment reminding the developer to verify the value fits their plan's cap (see Critical gaffa facts).settings.record_request off in emitted code.
Set it to true only while exploring a site or when the developer is debugging a failing request, so /gaffa-debug has a recording to inspect within the retention window, and drop it again once the request works.
Recording doubles the browser-time charge from 1 credit per 30 seconds to 2 credits per 30 seconds, say so in a code comment.proxy_location on every emitted browser request, us unless the target is clearly in another supported country or the developer asks for one.
With none set, the request goes out from a datacenter IP, which sites block more readily, so the residential IP is the safer default.
Proxies need a paid account, and all requests using proxy_location are billed at 750 credits per 1GB of bandwidth used, note the charge in a code comment.
If a request fails with a proxy or access error, retry it once without proxy_location, some sites are restricted on the proxy network."async": true, then poll the returned id).
Use "async": false only when the developer asks for a blocking call and the expected runtime is well under the plan's sync cap.templates/async-poll.md for the POST-then-poll pattern.templates/sync.md for the blocking "async": false pattern.templates/schema-extraction.md for /v1/schemas structured extraction./v1/schemas.
Same authoring trigger, constrained to one endpoint.
See templates/schema-extraction.md.
Storing a schema uses the endpoint with a normal API key.loop action.
Flag any step that relies on session state across separate requests as a known gaffa limitation.