Built-in tools

exray ships 15 MCP tools. Scopes come from TOOL_REQUIRED_SCOPE (admin satisfies any requirement):

ToolScopePurpose
scrapetools.executeRender a page → markdown/html/text/screenshot
extracttools.executeRender + pull structured JSON via a flat schema
crawltools.executeAsync chained crawl, returns a job_id
get_crawl_resultstools.readPage through per-page crawl artifacts
list_definitionstools.readList assets registered by this token
define_fetchertools.defineRegister a custom fetcher (TS source)
define_extractortools.defineRegister a custom extractor (TS source)
define_handlertools.defineRegister a result-page handler (TS source, entry handle(request, ctx))
define_crawltools.defineRegister a reusable crawl definition
delete_definitiontools.defineDelete a definition
define_evaltools.defineRegister an evaluation
run_evaltools.defineRun an evaluation
publish_definitiontools.publishPublish (draft → published; with a revision id, roll back)
deprecate_definitiontools.publishDeprecate (still runnable, flagged, drops out of tools/list)
disable_definitiontools.publishDisable (not runnable)

All 15 appear in tools/list and can be invoked via tools/call.

⚠️ But what they register differs: once published, define_fetcher / define_extractor assets show up in tools/list as fetcher_<name> / extractor_<name> for the agent to call. A handler registered by define_handler does not join the tool surface — it runs in response to public HTTP requests instead. See result pages.

scrape

Render a URL and return it in the requested format.

{
  "name": "scrape",
  "arguments": {
    "url": "https://example.com",
    "format": "markdown",
    "wait_for": ".main-content",
    "timeout_ms": 30000
  }
}
  • format: markdown (default) / html / text / screenshot (base64 PNG)
  • wait_for: optional CSS selector to wait for after render
  • actions: pre-render steps (click / wait_for_selector / scroll_to_bottom / wait_for_ms)
  • Failure codes: browser_render_failed / target_unreachable / http_error / budget_exceeded

extract

Render plus AI extraction against a flat schema.

{
  "name": "extract",
  "arguments": {
    "url": "https://news.ycombinator.com",
    "schema": {
      "title": "string",
      "score": "number",
      "comments": "number"
    }
  }
}
  • schema supports four types only: string / number / boolean / string[]
  • Returns { data, confidence, model_used }; confidence is a heuristic based on how many fields were populated
  • Default model: @cf/meta/llama-3.1-8b-instruct

crawl

Async: seeds are queued and it returns { job_id, seeds, stream_url } immediately. Poll with get_crawl_results.

{
  "name": "crawl",
  "arguments": {
    "seed_urls": ["https://blog.example.com/"],
    "include_patterns": ["https://blog.example.com/*"],
    "follow": "links",
    "max_depth": 2,
    "max_pages": 50,
    "extractor_params": { "schema": { "title": "string", "author": "string" } }
  }
}
  • follow: links (HTML anchors filtered by include_patterns) or result:<json-path> (take next URLs from an extractor's output field — giving a scrape→extract→scrape→extract chain)
  • def: reference a registered crawl definition (see define_crawl)
  • Hard caps: max_depth ≤ 10, max_pages ≤ 1000
  • Progress: MCP notifications/progress with progressToken = job_id

get_crawl_results

{
  "name": "get_crawl_results",
  "arguments": { "job_id": "<uuid>", "limit": 50 }
}

Returns { job_id, status, count, items, cursor }. Each item:

{
  "url": "...",
  "final_url": "...",
  "depth": 1,
  "metadata": { "title": "..." },
  "markdown": "...",
  "extracted": { }
}

When cursor isn't null, pass { job_id, cursor } to read the next page.

define_fetcher / define_extractor

Register custom TypeScript. See Writing extractors.

define_crawl

Register a reusable crawl configuration, shared by three trigger paths — crawl { def }, cron, and external triggers:

{
  "name": "define_crawl",
  "arguments": {
    "name": "blog-daily",
    "seed_urls": ["https://blog.example.com/"],
    "follow": "links",
    "include_patterns": ["https://blog.example.com/*"],
    "max_depth": 2,
    "cron": "0 9 * * *",
    "trigger_secret": "s3cr3t"
  }
}
  • cron: the server scans every 5 minutes and fires on match, so 5 minutes is the effective granularity
  • trigger_secret: stored as an Argon2 hash; POST /api/triggers/<id> fires the crawl with it

Error codes

CodeMeaning
invalid_inputArguments failed validation
forbiddenInsufficient scope
budget_exceededMonthly quota exhausted
unknown_toolNo such tool
unknown_crawl_defNo such crawl definition
browser_render_failed / target_unreachable / http_errorRender failed
bundle_missingCode bundle not found in R2
definition_incompatible_apiThe exray-api major the agent compiled against differs from runtime
fetcher_invalid_output / fetcher_runtime_errorCustom fetcher failed
extractor_invalid_output / extractor_runtime_errorCustom extractor failed
extract_failedBuilt-in extract failed
not_foundCrawl job doesn't exist or doesn't belong to this token