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
rowonly ({{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
tables_list/tables_query— latest SiteCrawler crawl,COUNT(*)and a 10-row sample of the population.tool_documentationhtml_code_executor— confirm the JS DOM API.batch_rundry_run: true—sqlsource,item_map: {url: "{{row.url}}"}, JS intool_config.function.- Same call,
dry_run: false. Ifmodeisjob, pollbatch_status. batch_samplea few records. Chain thesink.urlor 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: trueintool_config(shared by every record) so each result is a temporary URL, then batchhtml_code_executor/html_grep/tmp_storage_grepon those URLs, or - skip the fetch and batch
html_code_executor/html_grepon 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_feedbackafter delivering work, or after new indications on the samemain_task. - Call a tool in a loop because
itemsonly takes one element. tables_querythe full URL list, then feed it asrowsor as N tool calls.- Use a
rowssource for more than a handful of small objects. - Raise
batch_sample.limit(ortables_query.max_results) to pull a batch back into the context. - Batch
html_fetchand then read the HTML. - Pull a bulky SERP or HTML payload inline when you only need a few fields;
use
save_to_tmp_storageandtmp_storage_jq/tmp_storage_grep. - Use
tmp_storage_readon a file you could query withtmp_storage_jqortmp_storage_grep. - Target
batch_runwithbatch_run. - Skip
dry_runon a newitem_mapor 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.