Agentic Platform
Documentation API reference Tools Skills

Skill

botify-mcp

Call Botify MCP tools correctly, and fan a tool out with batch_run so lists of URLs or rows never enter the conversation. Use whenever the user is on the Botify MCP, asks to run a tool on many URLs, pages or rows, mentions batch_run, batch_status, batch_sample, batch_cancel, or wants to keep I/O out of the LLM. Also use as soon as there are more than about ten items to process, or when a tool returns a temporary storage URL (*_tmp_storage_url) to read with tmp_storage_jq, tmp_storage_grep or tmp_storage_read. After delivering work, call send_feedback; call it again if the user continues the same main_task.

Botify MCP

Every Botify MCP tool processes exactly one item. Fanning a tool out is batch_run's job: the records are read from a SQL query, a table or a file, and the results are written to a sink. They do not pass through this conversation.

For SQL over the catalog (tables_list, tables_query, catalog. prefix), also read ../botify-tables-sql/SKILL.md.

Tools may be deferred: call tool_search (e.g. tool_search(query="batch_run html_code_executor")) before the first use. Every call except list_projects needs organization and project. Every agent tool except send_feedback also needs main_task and sub_task as top-level arguments next to those slugs — not inside config or items. If the slugs are unknown, call list_projects rather than guessing. Retry a timed-out connector once or twice before treating it as a failure.

After delivering work — a question answered, a batch finished — call send_feedback. Same if a tool failed unexpectedly. If the user then gives new indications, stay on the same main_task and call send_feedback again when there is new feedback. Do not call it every turn.

One item per call

tool_name
  organization: acme
  project: www
  main_task: Internal linking audit
  sub_task: List available tables
  items:
    - { …one item matching the tool's schema… }
  config: { …optional, shared settings… }

main_task is the label of the overall task; reuse it on every call, including when the user adds new indications. sub_task is the label of the current step. send_feedback does not take them. Do not put them in config.

items is an array of length 1. A second element is refused with an error that names batch_run. Do not loop the same tool tens of times as a workaround.

Exception: speedworkers_page_history takes a list of up to 1,000 URLs inside its one item and scans its table once per call. Put every URL in that list rather than batching it per URL.

Standalone tools (no items): list_projects, batch_status, batch_sample, batch_cancel.

When to use batch_run

Situation What to do
One URL / one question Call the tool directly
2–10 small results you will read now batch_run is fine; the run stays inline and the results come back
More than ~10 records, or any list you do not want in context batch_run with a sql, table or file source
Large HTML, a crawl URL set, an uploaded CSV Same: batch_run. Never paste the rows into the prompt

The threshold is 10. At 11 records the run becomes a job: you get a job_id, not the data. That is the point.

For a job with several steps per item, if the workflows_* tools are in your tool list, read ../botify-workflows/SKILL.md.

The failure to avoid: tables_query a few thousand URLs, then call html_fetch / html_code_executor / html_grep once per row. The list has already entered the context, each page would again, and you will hit the one-item cap. Explore with aggregates and a LIMIT of 5–20, then hand the same SQL to batch_run as a sql source.

rows is the only source whose content travels through the model. It is capped at 256 KB and refused above that. Prefer sql or file.

batch_run

Read the target tool's item schema first (tool_documentation when it has extended docs — html_code_executor does). item_map must match that schema. batch_run cannot target itself.

batch_run
  organization: acme
  project: www
  main_task: Internal linking audit on last crawl
  sub_task: Extract product h1s
  items:
    - tool: html_code_executor
      source:
        type: sql
        query: |
          SELECT url
          FROM catalog.crawl_pages_20260816
          WHERE segments__pagetype__value = 'product'
            AND compliant__is_compliant
          LIMIT 500
      item_map:
        url: "{{row.url}}"
      tool_config:
        function: "function() { return {h1: document.querySelector('h1')?.innerText}; }"
      dry_run: true
  config:
    on_error: skip

Then the same call with dry_run: false.

Field Role
tool Target tool id (html_code_executor, html_grep, html_fetch, ai_gateway, …)
source Where records come from (sql, table, file, rows)
item_map Projection onto the tool's item. Omit when each record already has that shape
tool_config The target's config, shared by every record (JS function, grep queries, LLM schema)
sink Where results go. Default: a gzipped JSONL file whose URL is a valid file source
dry_run First 5 records only, results in full, nothing launched, no sink
config.on_error skip (default): record the failure and continue. fail_job: stop on the first error

Sources

type Use when Notes
sql The list already lives in the catalog BigQuery Standard SQL, catalog. prefix. Optional limit
table A whole catalog table Bare name or catalog.name. Optional limit
file A CSV/TSV the user uploaded, or the previous batch's JSONL URL from https://app.botify.com/:organization/:project/o/storage/tmp, a previous sink.url, or a public HTTP(S) URL. CSV, TSV, JSONL; gzip allowed. format defaults to auto
rows A handful of objects you already have Last resort. This is I/O through the LLM

Do not tables_query the full set and copy it into rows. A COUNT(*) plus LIMIT 20 is enough to understand the population; batch_run re-runs the query itself.

Ask the user to upload a local file rather than pasting it.

item_map

A projection, not an expression language: {{row.<column>}} and dotted paths. A path that does not resolve is refused on the sample before the job starts.

  • {"url": "{{row.page_url}}"} — whole placeholder: type is preserved (number stays a number)
  • "https://{{row.domain}}/{{row.path}}" — interpolation: always a string
  • {{row.blocks.0.text}} — walk objects and list indexes
  • Namespace is row only ({{rows.url}} is rejected)

Omit item_map when the source columns already are the tool's item (a rows source written in the item shape, or a SQL SELECT url against html_code_executor).

Sinks and chaining

Each written record is {item, response, error}. item is kept so a tool that does not echo its input stays attributable.

File (default). Gzipped JSONL. The returned sink.url is the file source of the next batch. A previous batch's lines become row.item.* and row.response.*:

item_map:
  user_prompt: "Classify this title: {{row.response.result.h1}}"

Table. Editable catalog, then query it with tables_query instead of pulling the file back.

sink:
  type: table
  table: product_h1s
  replace_existing: false
  row_map:
    url: "{{item.url}}"
    h1: "{{response.result.h1}}"

row_map sees {{item.*}}, {{response.*}} and {{row.*}}. It is not strict: a failed record has no response. The default (no row_map) stores raw item and response JSON columns. Never write into crawl/logs/GSC tables; pick a new name.

What comes back

mode When In the tool result
dry_run dry_run: true 5 {item, response, error} objects. No job, no sink
inline ≤ 10 records All results. A sink is still written if you asked for one
job > 10 records job_id, rows_total, estimated_credits, sink. results is null

Do not try to fetch a job's full output into the conversation.

Following a job

batch_status    organization, project, job_id
batch_sample    organization, project, job_id, limit (default 5, hard cap 20)
batch_cancel    organization, project, job_id

batch_status states: pending, running, completed, failed, cancelled. Poll it; do not start a second job for the same work. rows_ok / rows_error and sample_errors tell you whether to cancel.

batch_sample is a peek so you can check the mapping before trusting the sink. The cap of 20 exists because a batch exists to keep its data out of the chat. Do not raise limit to "download" the output.

batch_cancel stops at the next chunk. Records already written stay in the sink; the rest is not paid for.

Temporary storage URLs

Bulky payloads (HTML, SERP JSON) do not have to enter the conversation. Set save_to_tmp_storage: true in the tool's config and the result carries a temporary URL instead of the payload:

Tool Returns instead
html_fetch, speedworkers_page_preview, speedworkers_page_snapshot html_tmp_storage_url (no html)
google_serp, google_ai_overview, google_ai_mode, google_people_also_ask, google_trends results_tmp_storage_url (no results)

User uploads from https://app.botify.com/:organization/:project/o/storage/tmp are the same kind of URL. A URL only works in its own organization and project, and expires.

Look inside it with the tmp_storage_* tools, which return only what you ask for:

Tool Use for Limits
tmp_storage_jq JSON / JSONL: pick fields, filter, count max_results ≤ 1000 (default 100)
tmp_storage_grep Any text (HTML, CSV, JSON): a string or RE2 regex, with line number and context ≤ 20 queries, ≤ 1000 matches per query, 30 s scan
tmp_storage_read The whole file, when it is small and you need all of it Refused above 1 MiB decompressed

Gzip is decompressed for you, up to 100 MiB. Prefer tmp_storage_jq on JSON and tmp_storage_grep on HTML or text; tmp_storage_read is the last resort. When the JSON shape is unknown, start with expression: keys, or .[0:3] with slurp: true on JSONL.

tmp_storage_jq
  organization: acme
  project: www
  main_task: SERP competitors
  sub_task: Organic URLs for running shoes
  items:
    - url: <results_tmp_storage_url>
  config:
    expression: '.items[] | select(.type == "organic") | .url'

For many URLs, batch the tmp_storage_* tool over the previous sink: source: {type: file, url: <previous sink.url>}, item_map: {url: "{{row.response.results_tmp_storage_url}}"} and the jq expression or grep queries in tool_config.

Typical pipelines

1. Extract structured fields from many crawled URLs

  1. tables_list / tables_query — latest SiteCrawler crawl, COUNT(*) and a 10-row sample of the population.
  2. tool_documentation html_code_executor — confirm the JS DOM API.
  3. batch_run dry_run: true — sql source, item_map: {url: "{{row.url}}"}, JS in tool_config.function.
  4. Same call, dry_run: false. If mode is job, poll batch_status.
  5. batch_sample a few records. Chain the sink.url or query the table sink.

2. Grep a string or regex across a URL set

Same as above with tool: html_grep. url is the item; queries go in tool_config (shared by every record). Prefer this to fetching HTML and searching in the prompt.

3. Fetch HTML without pouring it into the chat

Do not batch html_fetch if you would then read html. Either:

  • set save_to_tmp_storage: true in tool_config (shared by every record) so each result is a temporary URL, then batch html_code_executor / html_grep / tmp_storage_grep on those URLs, or
  • skip the fetch and batch html_code_executor / html_grep on the live URLs (they load crawl HTML themselves).

4. User-supplied CSV

Upload at https://app.botify.com/:organization/:project/o/storage/tmp, then:

source:
  type: file
  url: <returned URL>
item_map:
  url: "{{row.url}}"

5. Second tool over the first batch's output

source:
  type: file
  url: <previous sink.url>
item_map:
  user_prompt: "{{row.response.result.h1}}"
tool_config:
  # ai_gateway system_prompt, structured_output_json_schema, model_settings

send_feedback

Call this after delivering work, and also when a tool fails unexpectedly or the data looks wrong. If the user continues the same main_task with new indications, call it again. Do not call it every turn.

Botify would be very grateful: say what went well or badly, and which tools you wished you had from an SEO/GEO product like Botify (crawl data, server logs, keywords, AI visibility, internal linking, and similar).

send_feedback
  organization: acme
  project: www
  items:
    - experience: negative   # positive | neutral | negative
      message: |
        html_code_executor could not read shadow-DOM product titles.
        A crawl-extracted JSON-LD tool would have helped.

experience is required (positive, neutral, or negative). message is the debrief.

Do not

  • Skip send_feedback after delivering work, or after new indications on the same main_task.
  • Call a tool in a loop because items only takes one element.
  • tables_query the full URL list, then feed it as rows or as N tool calls.
  • Use a rows source for more than a handful of small objects.
  • Raise batch_sample.limit (or tables_query.max_results) to pull a batch back into the context.
  • Batch html_fetch and then read the HTML.
  • Pull a bulky SERP or HTML payload inline when you only need a few fields; use save_to_tmp_storage and tmp_storage_jq / tmp_storage_grep.
  • Use tmp_storage_read on a file you could query with tmp_storage_jq or tmp_storage_grep.
  • Target batch_run with batch_run.
  • Skip dry_run on a new item_map or a new JS/LLM config.

Credits: a job reports estimated_credits at launch. dry_run is how you check a mapping without paying for the source.