External skill. The files live in GaffaAI/gaffa-for-ai and the CLI downloads them from commit 8ceb349, the version that was reviewed and scanned here. Newer commits reach this catalog only after a new review. License: MIT, by Impetus Labs.
SKILL.md

gaffa authoring

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.

Critical gaffa facts (grounding)

For anything beyond them, consult the live docs (see Doc-fetching strategy).

  1. Auth header is X-API-Key: <key>. Read from GAFFA_API_KEY env var. Never hard-code.
  2. 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.
  3. Max runtime is plan-tiered for both sync and async requests. Async maximum: 60 seconds on Pay As You Go and Starter, 2 minutes on Startup, 5 minutes on Growth. Sync maximum: 60 seconds on Pay As You Go and Starter, 90 seconds on Startup and Growth. Always set settings.time_limit explicitly.
  4. 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.
  5. Request recordings (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.

Verified endpoint and field reference

  • Endpoints: 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 fields under 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.
  • A non-200 response carries {"error": {"type", "id", "code", "message"}}, except 401, which is the plain text Invalid API Key, so do not JSON-parse an error body blindly.
  • If 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.
  • Available actions: 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.
  • Proxy locations are residential IPs: 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.

Choosing an extraction action

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.

  • Prefer a deterministic path when the value sits in a stable, well-structured place. Cheaper, repeatable, identical every run. Good for a price in a known element, a list of cards with a consistent shape, a field in a JSON blob. The options, in rough order of how often they fit:
    • parse_table with a selector for a table, which returns the rows already structured.
    • For repeating items (cards, list rows, search results), capture the whole containing region once, then extract every item from that one capture in the script, for example with an HTML parser. Capture the wrapper with 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.
  • Reach for 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.
  • Do not use 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.
  • Keep a deterministic per-item rule out of the schema. When a field is a fixed rule applied to text already being extracted (a word-contains flag, a first word, an arithmetic result), extract the raw text and compute the field in the script. The extraction can let the page's subject override the rule.
  • Return the structured values the task asked for, not a blob of markdown. Markdown from generate_markdown is a capture to parse in the script, not the final result.

Preferences

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.

  • Prefer an inline 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.

Credential hygiene

These rules apply to every line of code and every message this skill produces.

  1. Read GAFFA_API_KEY from env only. Never hard-code in emitted code. Reference it as ${GAFFA_API_KEY}.
  2. Never echo, log, narrate, or persist the value of GAFFA_API_KEY. Never put it in a URL or query string.
  3. Before showing a gaffa recording, error trace, or request body to the user, to an LLM judge, or to disk, strip the values of any fields whose names match (case-insensitive, including vendor-prefixed variants like gaffa_api_key): Authorization, X-API-Key, api_key (and apiKey, api-key), cookie, set-cookie. Replace the value with <REDACTED>.
  4. If the runtime value of 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).
  5. Persisted writes (the /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.
  6. If unsure whether a string is a secret, redact it.

Doc-fetching strategy

Resolve documentation queries in two tiers, in order.

  1. Preferred: gaffa docs MCP server at 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. askQuestion (param question, optional goal) for a direct answer with links to the source pages. sendFeedback (params content and pageUrl, optional goal) to report a docs page that is outdated, contradictory or missing something.
  2. Fallback: live HTTP fetch. ?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:

  1. JSON-RPC initialize over POST. Confirms the endpoint speaks MCP.
  2. Send the mandatory notifications/initialized notification.
  3. JSON-RPC 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 gaffa.dev and *.gaffa.dev and retry, see https://gaffa.dev/docs/ai-tools/tools/gaffa-ai-skills#allow-gaffa-through-your-network. Otherwise retry, or consult https://gaffa.dev/docs manually." If no gaffa MCP was configured, the message also suggests npx @gaffa-dev/cli install, which registers https://gaffa.dev/docs/~gitbook/mcp for faster lookups.

First action

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.

Behavior

  • Do not guess the page's markup. Whether the code needs a selector (for 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.
  • Reconnaissance informs the script, it does not change what the request asked for. When the request names an artifact, raw HTML to keep and parse, a screenshot, a table, deliver that. A 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.
  • Gaffa requests work best when targeting a single URL with actions performed on that page. Pagination and other repeat-the-same-steps flows stay inside that one request with the 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.
  • Always set 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).
  • Leave 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.
  • Set 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.
  • Default to the async pattern ("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.
  • Read the template that matches the task and adapt it to the developer's language and library. They are starting points, not literal output.
    • 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.

Schema design and SDK migration

  • Schema design with /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.
  • Migration from Playwright, Puppeteer, or Selenium to gaffa. Read the foreign snippet and map each step to a gaffa action, a pagination while-loop maps to the loop action. Flag any step that relies on session state across separate requests as a known gaffa limitation.

Next step

More skills in web-data, in alphabetical order.