---
name: webtotable
description: Start web research runs and read sourced tables from Web to Table. Use it when the user wants a list or table of companies, people, products or places with a source for each fact, or asks about their Web to Table tables.
version: 3
---

# Web to Table

Web to Table researches the web and builds a table. Each cell has a value, the URL of its source and a quote from that source.

- Research uses credits from the user's account, the same as in the app. A run stops at its cap.
- Reading tables, waiting for a run and checking the balance are free.
- Runs that an agent starts show in the user's Web to Table sidebar as normal threads. The user can watch, steer or stop them there.

## Install

Save this file as `webtotable/SKILL.md` in the skills folder of your agent. If an older copy is there, replace it. This is version 3.

- Claude Code: `~/.claude/skills/webtotable/SKILL.md`
- Codex: `~/.codex/skills/webtotable/SKILL.md`
- OpenClaw: `~/.openclaw/skills/webtotable/SKILL.md`
- Other agents: the skills folder of that agent.

## Auth

- REST base URL: `https://api.webtotable.app/v1`
- Header: `Authorization: Bearer $WEBTOTABLE_API_KEY`
- The key starts with `wtt_`. The user makes it at https://beta.webtotable.app/account/connections#agent. Keep it in the `WEBTOTABLE_API_KEY` environment variable.
- Never print the key. Never write it to a tracked file.
- MCP server (signs in with OAuth, no key): `https://api.webtotable.app/mcp/v1`

## Rules

1. Call get_credits first; if the balance is below the cap, tell the user before you start research.
2. research returns at once. Tell the user the estimate. Then do other work.
3. Call wait_for_table only after check_after. Use `checkAfterSeconds` from the last response. A call that comes early gets 429. get_run reads a run at once, without waiting.
4. Stop checking after 10 minutes and say so. Fetch the table later with get_table.
5. If the status is `waiting_for_user`, the run has a question. Ask your user the question with its options, then call answer_question before `expiresAt`. Pick an option yourself only when the user told you to choose. Never let a size question take its default without asking. If `answerWith` is `app`, give the user `threadUrl`.
6. To change a table you have (refine it, add columns, fix rows, ask about it), call send_message in its thread. Do not start a new research for it. If the reply has a plan card, show it to the user and call start_plan only after they agree to the plan and its size.
7. Ask your user before you spend credits: research, send_message, start_plan, a credits answer, and turning on "Keep this fresh" (set_schedule) or a watch. Each check of a fresh table uses credits up to its caps; tell the user the estimate first.
8. To use the user's own list or documents, upload_file first, then pass the `uploadId` in `files` of research or send_message.
9. When you show facts from a table, give their sources. get_cell_proof gives one cell's quote, source and stored page.
10. To get a file the user can download in the app, call list_files, then get_file. Links expire; fetch a new one when you need it.
11. Send a table to a connected tool with send_to only when the user asks. If the tool is not connected, give the user the link in the error.
12. A webhook (set_webhook) tells your system when a run asks, ends or a table changes, so you do not need to poll. Check its signature (see set_webhook).

## Tools

Each tool is an MCP tool and a REST call. Responses are JSON. Errors are `{"error": {"code", "message"}}`.
Every tool that changes something takes an optional `idempotencyKey`: send the same key again after a timeout and you get the first answer (with `"repeated": true`), not a second change.

### get_credits() — free
`GET /v1/credits`
```
curl "https://api.webtotable.app/v1/credits" -H "Authorization: Bearer $WEBTOTABLE_API_KEY"
```
Response: `{"balance": 1240, "available": 1240, "reserved": 0, "defaultCap": 1100, "maxCap": 5000}`

### research(prompt, cap?, files?) — uses credits, up to the cap
`POST /v1/research` with `{"prompt": "...", "cap": 1100, "files": ["optional uploadId"], "idempotencyKey": "optional"}`
```
curl -X POST "https://api.webtotable.app/v1/research" -H "Authorization: Bearer $WEBTOTABLE_API_KEY" -H "Content-Type: application/json" -d '{"prompt": "40 UK skincare brands with founder and Instagram", "cap": 1100}'
```
Response (202): `{"runId": "...", "conversationId": "...", "status": "started", "cap": 1100, "estimateMinutes": 10, "estimate": "about 10 minutes", "checkAfterSeconds": 60, "threadUrl": "..."}`
The cap defaults to 1100 credits. If the available credits are below the cap, the call fails with 402 `insufficient_credits`.
`files` takes uploadId values from upload_file (files in no thread yet): the run reads them, for example a list to enrich.

### wait_for_table(runId) — free
`GET /v1/runs/{runId}/wait`
```
curl "https://api.webtotable.app/v1/runs/RUN_ID/wait" -H "Authorization: Bearer $WEBTOTABLE_API_KEY"
```
Waits up to 55 s. Response: `{"runId", "conversationId", "status", "done", "progress", "elapsedSeconds", "checkAfterSeconds", "credits": {"spent", "cap", "charged"?}, "threadUrl", "question"?, "summary"?, "error"?, "table"?}`
`status` is one of `launching`, `running`, `waiting_for_user`, `delivering`, `completed`, `failed`, `cancelled`. `done` is true when the run ended. When it completed, `table` is the first page (see get_table).

### list_tables() — free
`GET /v1/tables?cursor=`
Response: `{"tables": [{"id", "title", "rowCount", "columns", "versions", "latestRunId", "updatedAt", "threadUrl"}], "nextCursor"?}`

### get_table(id) — free
`GET /v1/tables/{id}?cursor=&limit=&version=`
Response: `{"id", "version", "versions", "runId", "deliveredAt", "title", "columns": [{"name", "kind", "description"}], "rows": [{"id", "cells": {"Column": {"value", "sourceUrl", "quote"}}}], "rowCount", "nextCursor"?, "caveats"}`
Up to 500 rows a page. Pass `nextCursor` as `cursor` for the next page.

### get_changes(id, since) — free
`GET /v1/tables/{id}/changes?since=`
`since` is a version number, a run id or a time (ISO 8601). Response: `{"id", "from": {"version", "runId"}, "to": {"version", "runId"}, "added": [row], "removed": [row], "changed": [{"id", "cells": {"Column": {"before", "after"}}}], "counts", "truncated"}`

### get_dataset(id) — free
`GET /v1/tables/{id}/dataset?version=`
Response: `{"id", "runId", "url", "fileName", "bytes", "rows", "expiresAt", "files"}`. `url` downloads dataset.zip: data.csv, sources.csv, codebook.md, codebook.json, documents.csv, version.json and CITATION.txt. It works until `expiresAt` (about a day). Use it for a whole table of any size instead of paging get_table.

### search_rows(id, query) — free
`GET /v1/tables/{id}/rows?q=`
Response: `{"id", "version", "query", "rows": [row], "total", "matchedColumns"}`. The match is a case-insensitive substring of any cell value. At most 100 rows.

### get_run(runId) — free
`GET /v1/runs/{runId}`
Reads the run once and returns at once. It does not wait, and check_after does not apply. Response: `{"runId", "conversationId", "status", "done", "progress", "startedAt", "elapsedSeconds", "credits": {"spent", "held", "cap", "charged"?}, "question"?, "plan"?, "stopReason"?, "summary"?, "error"?, "tableId"?, "threadUrl"}`
`question` is the open question (see answer_question). `plan` is the plan the run started from: `{"planId", "version", "sentence", "columns", "scope", "sources", "wontDo", "capCredits"}`.

### answer_question(runId, questionId, answer) — free
`POST /v1/runs/{runId}/answer` with `{"questionId": "...", "answer": "small", "idempotencyKey": "optional"}`
`answer` is an option key, an option label, or your own words when `allowOther` is true. Response: `{"runId", "questionId", "answered": true, "answer", "checkAfterSeconds": 0}`. You can call wait_for_table at once after it.
If the question already has an answer: `{"answered": false, "alreadyAnswered": true, "answer"}`. A run that ended gets 409 `ended`.
A `keys` question takes `credits`, `retry`, `deliver` or `pause`. `credits` (or `credits:<n>`, which also sets the credit cap to n) uses Web to Table credits and needs a key with `research:run`.
A `sign_in` or `reconnect` question needs the user in the app: give them `threadUrl`.

### stop_run(runId) — free
`POST /v1/runs/{runId}/stop` with `{"idempotencyKey": "optional"}`
Response: `{"runId", "status", "stopRequested": true}`. The run ends as `cancelled` within about a minute; follow it with wait_for_table. A run that is checking its table ends at once as `completed` with that table. A run that ended gets 409 `ended`. Stop a run only when your user asks.

### list_review_items(runId | conversationId) — free
`GET /v1/reviews?runId=` or `GET /v1/reviews?conversationId=&onlyOpen=true`
Response: `{"reviews": [{"reviewId", "runId", "title", "explanation", "column"?, "runActive", "counts", "items": [{"itemId", "row", "column"?, "proposed", "current", "confidence", "reason", "sourceUrl"?, "quote"?, "status", "decidedValue"?, "rowCount", "applied"?}]}]}`
`status` is `proposed` (waits for a decision), `approved`, `edited` or `rejected`. `current` is the value the table holds by that status. Items come least sure first.

### decide_review_items(reviewId, decisions?, approveAllOpen?) — free
`POST /v1/reviews/{reviewId}/decide` with `{"decisions": [{"itemId": "...", "action": "approve"}, {"itemId": "...", "action": "edit", "value": "..."}], "idempotencyKey": "optional"}`
`action` is `approve`, `reject` (the cells become empty), `edit` (use `value`) or `undo` (back to proposed). `{"approveAllOpen": true}` approves every item that still waits, up to 50 a call. At most 50 decisions a call; all or none are saved.
Response: `{"reviewId", "decided": [{"itemId", "status", "applied"}], "approved"?, "remaining"}`. `applied` is `applied` (the table changed), `pending` (a run of the table still works; its end applies it) or `kept` (no table row uses it yet). Decide only what your user decided.

### send_message(conversationId, text, files?, cap?) — free, or uses credits when it starts a run
`POST /v1/conversations/{conversationId}/messages` with `{"text": "Add a column with the founder's LinkedIn", "files": ["optional uploadId"], "cap": 1100, "idempotencyKey": "optional"}`
Continues the thread, as the user does in the app. Use it for any change to an existing table: more rows, a new column, a fix, or a question about the table. Do not start a new research for that.
Response: `{"conversationId", "action", "threadUrl", ...}`. `action` is one of:
- `chat`: Web to Table answers in the thread. `{"chat": {"questionId", "state", "done", "reply"?, "citations"?, "question"?, "plan"?, "run"?}}`. The call waits up to about 25 s for the reply. If `done` is false, read the reply later with get_conversation. A `plan` is a plan card: show it to the user and call start_plan only after they agree. A `question` needs the user's answer: send it with answer_chat_question.
- `run_started` (202): `{"runId", "cap", "estimateMinutes", "estimate", "checkAfterSeconds"}`. Call wait_for_table after checkAfterSeconds. Web to Table can also answer without new research: then the run ends at once with the answer as `summary`.
- `steered`: a run was working. The message goes to that run as a change. `{"runId", "status"}`. Files you attach go to the next run.
- `answered_question`: the run was waiting on a question. The text is the answer. `{"runId"}`.
The cap works as in research. If a run would start and the available credits are below the cap, the call fails with 402 `insufficient_credits`.

### answer_chat_question(questionId, key?, text?) — free
`POST /v1/chat-questions/{questionId}/answer` with `{"key": "b", "idempotencyKey": "optional"}` or `{"text": "Only shops with a website"}`
Answers a question that Web to Table asked in the thread chat. Ask the user first; never choose for them. Response: `{"conversationId", "action": "chat", "chat": {...}, "threadUrl"}`, as send_message.
For the question of a working run (`waiting_for_user`), use answer_question instead.

### start_plan(planId, size?, cap?) — uses credits, up to the cap
`POST /v1/plans/{planId}/start` with `{"size": "medium", "cap": 1100, "idempotencyKey": "optional"}`
Starts the newest version of a plan card. Call it only after the user agreed to the plan and the size. Response (202): `{"runId", "conversationId", "planId", "version", "size", "status": "started", "cap", "estimateMinutes", "estimate", "checkAfterSeconds", "threadUrl"}`.
Plan cards come only when chat and plans are on for the account; else the call fails with 409 `refused`.
- 409 `conflict` with `plan`: the price changed. Show the user the new sizes, then call again with a new idempotencyKey.
- 409 `conflict`: a newer plan replaced this one, or a run works in the thread.
- 402 `insufficient_credits`: the available credits are below the cap.

### get_conversation(conversationId) — free
`GET /v1/conversations/{conversationId}`
Response: `{"conversationId", "title", "createdAt", "updatedAt", "startedBy"?, "threadUrl", "messages": [{"role", "text", "at"}], "runs": [{"runId", "status", "startedAt", "summary"?}], "liveRun": {"runId", "status", "progress"?, "question"?} | null, "chat": {"questionId", "state", "done", "reply"?, "question"?} | null, "plan": {...} | null, "table": {"id", "title", "rowCount", "columns", "latestRunId", "updatedAt"} | null, "files": [{"uploadId"?, "name", "type", "size", "addedAt"}]}`
`messages` holds the last 30 messages: the user's messages, the replies and the run summaries. `chat` is the latest chat turn. `plan` is the newest plan card: `{"planId", "version", "state", "summary", "rowUnit"?, "sizes": [{"key", "rows", "credits", "creditsMax"?, "minutes"}], "defaultSize", "columns": [{"name", "kind", "from"?}], "scope", "sources", "wontDo", "method"?}`.

### list_conversations() — free
`GET /v1/conversations?cursor=`
Response: `{"conversations": [{"conversationId", "title", "updatedAt", "hasTable", "rowCount"?, "liveRun": {"runId", "status"} | null, "startedBy"?, "threadUrl"}], "nextCursor"?}`. 20 threads a page.

### upload_file(name, contentBase64? | url?, conversationId?, type?) — free
`POST /v1/files` with `{"name": "accounts.csv", "contentBase64": "...", "conversationId": "optional", "idempotencyKey": "optional"}` or `{"name": "report.pdf", "url": "https://..."}`
Uploads the user's own file: a list to enrich (CSV, Excel .xlsx, JSON) or documents to read (PDF, Word .docx, text, Markdown, images). contentBase64 takes up to 10 MB of file; use url (public, https) for files up to 25 MB.
Response (201): `{"uploadId", "name", "type", "size", "conversationId"?}`.
Then pass the uploadId in `files` of research (a new table from the file) or send_message (an existing thread). With conversationId, the file joins that thread now and the next run of the thread reads it.
- 413 `too_large`: the file is too big. 415 `unsupported`: the file type is not one of the types above.

### get_related_tables(id, version?) — free
`GET /v1/tables/{id}/related?version=`
Response: `{"id", "version", "runId", "main": {"name", "label", "rowCount"}, "tables": [{"name", "label", "title", "description"?, "rowCount", "columns": [{"name", "kind", "description", "links"?}], "links": [{"column", "toTable", "toColumn"}], "linkedFrom": [{"table", "column", "toColumn"}], "checks"?: {"passed", "total"}}]}`
Some answers have more than one table, for example a list and a mapping of codes. `links` names the columns of this table whose values name rows of another table. `linkedFrom` names the columns of other tables that point at this one. `tables` is empty for a one-table answer.
To read the rows of a related table, call get_table with `related` set to its name: `GET /v1/tables/{id}?related=NAME&cursor=`. The pages work as for the main table.

### get_cell_proof(id, rowId, column) — free
`GET /v1/tables/{id}/cells/proof?rowId=ROW_ID&column=COLUMN`
Response: `{"id", "runId", "rowId", "column", "label", "value", "quote"?, "sourceUrl"?, "host"?, "note"?, "check"?: {"status", "explanation", "checkedAt"}, "document"?: {"sha256", "title"?, "period"?, "contentType", "bytes", "pages"?, "page"?, "region"?: {"page", "bbox", "size"}, "locator"?, "foundAt"?, "downloadUrl", "downloadExpiresAt"}, "history"?: [{"change", "before"?, "after"?, "sourceUrl"?, "at", "kind", "reason"?}]}`
Use it when the user asks where one value comes from. `check.status` is the result of the check of that cell (for example `supported` or `contradicted`). `document` is there when the value came from a document Web to Table stored. `region.bbox` is the value's box on the page in points (x0, y0, x1, y1; origin at the top left of a page of `size`). `downloadUrl` downloads the stored copy without a key and works until `downloadExpiresAt` (one hour); add `#page=N` to open a PDF at the page. `history` lists the earlier changes of that cell and its row when the table is kept fresh, newest first: `change` is `added`, `removed`, `changed` or `source_gone`, and `kind` is the kind of that version (see get_versions, and `user_edit`).

### get_versions(id) — free
`GET /v1/tables/{id}/versions`
Response: `{"id", "latest", "keptFresh", "versions": [{"version"?, "runId", "deliveredAt", "kind", "rowCount", "changes"?: {"added", "removed", "changed", "rechecked", "unchanged"}, "changesPending"?, "credits", "edits"?: [{"at", "reason"?, "changes"}]}], "truncated"?}`
`kind` is `first_run`, `follow_up`, `check` (a scheduled check of a table that is kept fresh) or `discovery` (a check that also looks for new rows). `version` is the number get_table, get_changes and get_dataset take; a version after the 20th has no number, so use its `runId` as the id. `edits` are the user's own changes in the app to that version. `credits` is what the run of that version cost. For a table that is kept fresh, `changes` counts the rows and cells that changed against the version before; `changesPending` means the count is not ready yet.

### send_to(id, destination, name?, spreadsheetId?, tab?, keepUpdated?, related?) — free
`POST /v1/tables/{id}/send` with `{"destination": "google_sheets", "name": "optional", "keepUpdated": true, "idempotencyKey": "optional"}`
Response (202): `{"pushId", "id", "runId", "destination", "destinationName", "targetName", "status", "rowsTotal", "keepUpdated", "table"?, "tabName"?, "externalUrl"?, "checkWith"}`. `targetName` is the name of the spreadsheet.
Sends the table to a tool the user connected, the same as Send to in the app. It needs a key with `tables:write`.
- A new spreadsheet: send `name`, or nothing for the table title.
- An existing spreadsheet: send `spreadsheetId` from list_destinations. Send `tab` to update that tab, or no tab for a new tab.
- `keepUpdated` (default true) sends each later version of the table to the same place.
- `id` can be a runId to send that version. `related` sends a related table instead of the main table.
The send runs in the background. Call list_destinations with the `pushId` until `status` is `done` or `failed`. A repeat with the same arguments while the send waits returns the same `pushId`.
If the tool is not connected, the call fails with 409 `not_connected` and `connectUrl`. Tell the user to connect it there, then send again.

### list_destinations(id?, pushId?) — free
`GET /v1/destinations?id=&pushId=`
Response: `{"destinations": [{"destination", "name", "status", "available", "account"?, "spreadsheets"?: [{"spreadsheetId", "name", "tabs", "url"}]}], "connectUrl", "sends"?: [{"pushId", "destination", "destinationName", "targetName", "status", "runId", "table"?, "rowsTotal", "rowsDone", "rowsNew", "rowsUpdated"?, "rowsMarked"?, "tabName"?, "externalUrl"?, "keepUpdated", "trigger", "createdAt"?, "finishedAt"?, "nextRetryAt"?, "error"?}]}`
`status` of a destination is `connected`, `expired` (the user must connect it again) or `not_connected`. `available` is false for a tool that comes later. `status` of a send is `queued`, `running`, `done`, `failed`, `uncertain` or `cancelled`; `externalUrl` opens the sheet. A failed try waits and tries again; `nextRetryAt` says when.

### get_schedule(id, recheck?, discover?) — free
`GET /v1/tables/{id}/schedule?recheck=&discover=`
Response: `{"id", "title", "fresh", "status", "paused"?, "schedule": {"recheck", "discover", "time", "timeZone", "weekday", "monthDay"}, "caps": {"perCheck", "perMonth", "spentThisMonth"}, "nextCheckAt"?, "lastChecks": [{"runId", "mode", "trigger", "startedAt", "outcome", "credits"}], "alerts": {"rules": [{"id", "text", "readAs", "kind"}], "digest", "delivery": {"email"?, "slack", "webhookUrl"?}}, "watches", "estimate": {"recheckCredits", "discoverCredits", "perDayCredits", "perMonthCredits", "defaultPerCheck", "defaultPerMonth", "balance"}, "limits": {"plan", "cadences", "capPerCheck", "capPerMonth", "alertRules"}, "threadUrl"}`
`fresh` is true when the table has checks on. `status` is `off`, `active`, `failing` or `paused`. When `fresh` is false, `schedule` and `caps` show the defaults that set_schedule uses. `limits.cadences` lists the cadences this account can choose. Times are milliseconds since 1970 (UTC).

### set_schedule(id, enabled?, recheck?, discover?, time?, timeZone?, capPerCheck?, capPerMonth?, alertRules?, ...) — uses credits on every check
`POST /v1/tables/{id}/schedule` with `{"enabled": true, "recheck": "daily", "discover": "weekly", "time": "07:00", "timeZone": "Europe/London", "capPerCheck": 10, "capPerMonth": 200, "idempotencyKey": "optional"}`
Only the fields you send change. Other fields: `weekday` (monday, or 0 to 6), `monthDay` (1 to 28), `digest` (instant, daily, weekly), `email`, `slackWebhookUrl`, `webhookUrl` (`""` removes one).
Cadences: `daily`, `weekdays`, `weekly`, `monthly`, `off`, `every-3-days` (2 to 90 days), and `hourly` on the Scale plan only (else 403 `plan_limit`).
`alertRules` replaces every rule: `[{"text": "when a price drops by more than 10%", "kind": "cell_changed", "column": "price", "comparison": {"op": "decreases_by", "value": 10, "unit": "percent"}}]`. Kinds: `row_added`, `row_removed`, `cell_changed`, `threshold` (comparison `above` or `below`), `any_change`. A `filter` (`{"column", "op", "value"}`, op `contains`, `equals`, `gt`, `lt`...) keeps only some rows. Columns must be columns of the table.
Response: the same object as get_schedule, plus `"action"`: `created`, `updated`, `paused`, `resumed` or `unchanged`.
Turning checks on, resuming, raising a cap or checking more often needs a key with `research:run`. Pausing, lowering a cap or checking less often needs only `tables:write`.

### list_watches(id) — free
`GET /v1/tables/{id}/watches`
Response: `{"id", "fresh", "watches": [{"id", "url", "kind", "reason", "status", "everyHours", "nextWatchAt"?, "lastCheckedAt"?, "failures", "pendingItems", "checks": [{"at", "outcome", "added"?, "runId"?}]}], "limits": {"everyHours": {"min": 1, "max": 24}}}`
`status` is `proposed` (it waits for set_schedule to turn checks on), `active`, `failing` or `paused`.

### set_watch(watchId, enabled?, everyHours?) — free; a watch can start checks that use credits
`POST /v1/watches/{watchId}` with `{"enabled": false, "everyHours": 12, "idempotencyKey": "optional"}`
Response: the watch, as in list_watches. A watch can be on only when its table has checks on (else 409 `refused`). Turning a watch on or reading more often needs `research:run`.

### get_usage(runId?, since?, cursor?) — free
`GET /v1/usage?since=&cursor=&limit=&runId=`
Response: `{"runs": [{"runId", "kind", "status", "title", "conversationId", "startedAt", "finishedAt"?, "startedBy", "credits": {"charged"?, "spent", "cap"}, "check"?: {"mode", "trigger"}, "threadUrl"}], "nextCursor"?, "totals": {"since", "charged", "research", "checks", "runs", "partial"?}, "balance": {"balance", "available", "reserved"}}`
`kind` is `research` or `check` (a check that keeps a table fresh). `credits.charged` is there once the run ended; until then `credits.spent` is what it used so far. Pass `nextCursor` as `cursor` for older runs.

### list_files(runId | conversationId) — free
`GET /v1/files?runId=` or `GET /v1/files?conversationId=`
Response: `{"runId"? , "conversationId", "files": [{"fileId", "name", "kind", "formats", "bytes"?, "about", "runId"?, "version"?, "table"?}], "more"?}`
`kind` is one of `table_export`, `codebook`, `sources_audit`, `dataset`, `related_table`, `answer_tables`, `saved_rows`, `reply_file`, `evidence_file`, `files_zip`, `source_document`, `upload`. `formats` lists the formats you can ask for; the first is the default. A thread lists the files of its 20 newest runs; `more` is true when it has older runs.

### get_file(fileId, format?) — free
`GET /v1/files/{fileId}?format=xlsx`, or by kind: `GET /v1/files?runId=&kind=table_export&format=dta`
Response: `{"fileId", "name", "kind", "format", "contentType", "bytes", "url", "expiresAt", "content"?}`
`url` downloads the file with no key. It works until `expiresAt` (at least 30 minutes; a dataset about a day). Ask again for a new link. `content` holds the text of a CSV, JSON or Markdown file up to 256 KB. A format the file does not offer gets 400 `invalid_request`.

### set_webhook(url, events, secret?, webhookId?) — free
`POST /v1/webhooks` with `{"url": "https://example.com/hooks/w2t", "events": ["run.completed", "run.question"], "idempotencyKey": "optional"}`
Registers a URL for events in this account. Response (201 for a new webhook): `{"webhook": {"webhookId", "url", "events", "status", ...}, "secret"?, "created"}`.
If you do not send `secret`, Web to Table makes one (`whsec_...`) and returns it in `secret` only this once. Save it.
Send `webhookId` to change `url`, `events` or `secret`. This also turns a disabled webhook on again.
The URL must be public and use https://. An account has at most 10 webhooks.
Event types: `run.question`, `run.completed`, `run.failed`, `run.stopped`, `plan.proposed`, `table.changed`, `watch.triggered`.

### list_webhooks() — free
`GET /v1/webhooks`
Response: `{"webhooks": [{"webhookId", "url", "events", "status", "disabledReason"?, "disabledAt"?, "failedEvents", "lastDeliveredAt"?, "createdBy", "createdAt", "updatedAt"}], "max": 10}`
`status` is `active` or `disabled`. A webhook turns off when it answers 410, or when every try failed for 20 events in a row or for 24 hours. `disabledReason` says why. Turn it on again with set_webhook and its `webhookId`.

### delete_webhook(webhookId) — free
`POST /v1/webhooks/{webhookId}/delete` with `{"idempotencyKey": "optional"}`
Response: `{"webhookId", "deleted": true}`. Events that wait for a retry are not sent.

### list_webhook_deliveries(webhookId?, cursor?) — free
`GET /v1/webhooks/deliveries?webhookId=&cursor=`
Response: `{"deliveries": [{"eventId", "type", "webhookId", "createdAt", "state", "attempts", "lastStatusCode"?, "lastError"?, "nextRetryAt"?, "deliveredAt"?}], "nextCursor"?}`
`state` is `pending` (a try waits), `delivered` (a 2xx answer) or `failed` (no more tries). Times are Unix milliseconds. 50 a page.

## Webhook events

Each event is a POST with a JSON body: `{"id", "type", "createdAt", "data": {...}}`. `createdAt` is Unix milliseconds.
- `run.question`: the run asks the user. `data`: `runId`, `tableId`, `questionId`, `question`, `options` (`key`, `label`), `default`?, `allowOther`, `expiresAt`?, `threadUrl`, `answer` (how to answer).
  Answer with the answer_question tool, or `POST /v1/runs/{runId}/answer`. If nobody answers before `expiresAt`, the run takes `default`.
- `run.completed`: `runId`, `tableId`, `summary`, `rowCount`, `links` (`thread`, `table`: `GET /v1/tables/{id}`, `dataset`: get_dataset).
- `run.failed` and `run.stopped`: `runId`, `tableId`, `status`, `summary`?, `error`?, `stopReason`?, `links`.
- `plan.proposed`: the chat proposes a plan. `planId`, `version`, `tableId`, `request`, `columns`, `sizes`, `defaultSize`, `threadUrl`, `start` (how to start it).
  Ask your user, then start it with the start_plan tool, or `POST /v1/plans/{planId}/start`.
- `table.changed`: a living table has a new version (`trigger`: `version`) or alerts (`trigger`: `alert`). `tableId`, `version`, `kind`?, `changeCount`?, `alerts`?, `links`. Read the changes with get_changes.
- `watch.triggered`: a watch found new items. `watchId`, `tableId`, `url`, `added`, `missing`, `sample`, `refreshStarted`, `runId`?.

Headers of each POST:
- `Webhook-Id`: the event id. It is the same on every try of the event.
- `Webhook-Timestamp`: Unix seconds of this try.
- `Webhook-Signature`: `v1=` and the hex HMAC-SHA256 of `{timestamp}.{body}`, with your secret as the key.
- `User-Agent`: `WebToTable-Webhooks/1`.

Answer 2xx within 10 seconds. Do slow work after you answer. Any other answer, a redirect or no answer is a failed try.
Web to Table tries again after 1, 5, 15 and 40 minutes (5 tries). It does not follow redirects. Answer 410 to turn the webhook off.
An event can arrive more than once. Keep the `Webhook-Id` values you processed and skip a repeat.
Events can arrive out of order. Use `createdAt`, or read the run or table again.

Verify each POST before you trust it. Use the raw body, not parsed JSON. Reject a timestamp older than 5 minutes.

Node:
```js
import { createHmac, timingSafeEqual } from 'node:crypto';

// headers: lower-case names. rawBody: the body as a string, before JSON.parse.
function verifyWebToTable(secret, headers, rawBody) {
  const timestamp = headers['webhook-timestamp'] ?? '';
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = Buffer.from('v1=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'));
  return (headers['webhook-signature'] ?? '').split(',').some(part => {
    const given = Buffer.from(part.trim());
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

Python:
```python
import hashlib, hmac, time

# headers: a dict with the header names as sent. raw_body: the body bytes, before json.loads.
def verify_web_to_table(secret: str, headers: dict, raw_body: bytes) -> bool:
    timestamp = headers.get("Webhook-Timestamp", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    digest = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    expected = "v1=" + digest
    return any(hmac.compare_digest(part.strip(), expected) for part in headers.get("Webhook-Signature", "").split(","))
```

## Errors

- 400 `invalid_request`: a field is wrong. The message names it.
- 401 `unauthorized`: the key is missing, wrong, revoked or expired.
- 402 `insufficient_credits`: the available credits are below the cap. Tell the user.
- 403 `forbidden`: the key does not have the scope. `tables:read` reads, `tables:write` makes changes that use no credits, `research:run` uses credits (and includes `tables:write`). `plan_limit`: the account's plan does not allow this choice.
- 404 `not_found`: no such run, table, thread or file in this account.
- 409 `conflict`, `ended`, `not_connected` or `refused`: the change cannot happen now. The message says why. `idempotency_conflict`: the idempotencyKey was used for other arguments. `in_progress`: the first call with that key still runs.
- 413 `too_large` and 415 `unsupported`: the file is too big or of a type Web to Table cannot read.
- 429 `rate_limited` or `too_early`: wait for `Retry-After` seconds. Limits: 30 calls a minute per key, 60 per account, one wait_for_table per check_after.

## One prompt

To set up Web to Table for every agent on a machine, an agent that runs commands can follow this prompt:

```
Set up Web to Table in the current folder. Run the commands yourself, without asking the user. Ask the user only when a step needs them, for example a browser sign-in.

1. Install the skill. Fetch https://api.webtotable.app/SKILL.md. Save it as webtotable/SKILL.md in the skills folder of every agent on this machine that reads skills. The file lists the folder for each agent. If an older copy is there, replace it.

2. Add the Web to Table MCP server for every coding agent on this machine, not just the one you are. For each command below that exists on this machine, run it. Skip agents that are not installed. If an agent already has a server named webtotable, leave it as it is.
   claude mcp add --transport http --scope user webtotable https://api.webtotable.app/mcp/v1
   codex mcp add webtotable --url https://api.webtotable.app/mcp/v1
   gemini mcp add --scope user --transport http webtotable https://api.webtotable.app/mcp/v1
   For Cursor, add "webtotable": { "url": "https://api.webtotable.app/mcp/v1" } under "mcpServers" in ~/.cursor/mcp.json. Keep the servers that are already there.
   For OpenCode, add "webtotable": { "type": "remote", "url": "https://api.webtotable.app/mcp/v1", "enabled": true } under "mcp" in ~/.config/opencode/opencode.json.

3. Set the API key for the skill. If WEBTOTABLE_API_KEY is not set, ask the user to paste their key from https://beta.webtotable.app/account/connections#agent. Save it as WEBTOTABLE_API_KEY in .env.local in this folder, and add .env.local to .gitignore. Never print the key or write it to a tracked file.

4. Verify. For each agent you changed, list its MCP servers (for example: claude mcp list) and check that webtotable is there. Then call GET https://api.webtotable.app/v1/tables with the key. This call is free and must return 200.

5. Then tell the user what was installed: one line per agent, with "skill", "MCP" or both. List the agents that still need a browser sign-in, and say how to do it. For example: "In Claude Code, type /mcp, select webtotable and choose Authenticate." When you run research later: Call get_credits first; if the balance is below the cap, tell the user before you start research. Also tell the user how research works: a run takes minutes, you tell them the estimate, and you check back only at the time the server gives in check_after.
```
