{
  "name": "grist.tools",
  "description": "Deterministic HTTP micro-utilities for AI agents, paid per call via x402.",
  "site": "https://grist.tools",
  "network": "eip155:8453",
  "openapi": "https://grist.tools/openapi.json",
  "manifest": "https://grist.tools/.well-known/x402",
  "count": 50,
  "endpoints": [
    {
      "name": "archive-extract-file",
      "summary": "Extracts one named entry from a remote zip, tar or tar.gz and returns its bytes (base64) with a sha256.",
      "rationale": "Pulling a single file out of an archive without unpacking the whole thing needs a real reader, a hardened fetch and the decompression-bomb care that caps a single entry twice, not something an agent should run inline.",
      "family": "archives",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 90000,
      "max_timeout_seconds": 111,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/archive-extract-file",
      "schema_url": "https://grist.tools/v1/archive-extract-file/schema",
      "docs_url": "https://grist.tools/docs/archive-extract-file",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the archive (at most 25 MB, also the cap on an inflated tar.gz), up to 2048 characters; redirects are followed and the format (zip, tar or gzip-compressed tar) is decided from the magic bytes, not the extension."
          },
          "entry": {
            "type": "string",
            "minLength": 1,
            "maxLength": 4096,
            "description": "Exact entry path to extract, 1 to 4096 characters, matched case-sensitively against the names archive-inspect lists; directories, links, encrypted entries and entries over 25 MB are refused."
          }
        },
        "required": [
          "url",
          "entry"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/archives/bundle.zip",
        "entry": "data/squares.csv"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/archives/bundle.zip",
        "kind": "zip",
        "entry_name": "data/squares.csv",
        "size": 31,
        "compression": "deflate",
        "crc32": "89761172",
        "sha256": "202663eb5661a93d557520ce47e40bb6ccdcd7fded543fca46592d2291e2c8cf",
        "content_base64": "bixzcXVhcmUKMSwxCjIsNAozLDkKNCwxNgo1LDI1Cg=="
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "archive-inspect",
      "summary": "Lists the entries of a remote zip, tar or tar.gz (paths, sizes, compression and CRC) without extracting any of them.",
      "rationale": "Seeing inside an archive without extracting it needs a real zip/tar reader and a hardened fetch, plus the decompression-bomb care that reads declared sizes instead of inflating, not something an agent does inline.",
      "family": "archives",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 60000,
      "max_timeout_seconds": 81,
      "max_bytes": 104857600,
      "endpoint": "https://grist.tools/v1/archive-inspect",
      "schema_url": "https://grist.tools/v1/archive-inspect/schema",
      "docs_url": "https://grist.tools/docs/archive-inspect",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the archive (at most 100 MB, also the cap on an inflated tar.gz), up to 2048 characters; redirects are followed and the format (zip, tar or gzip-compressed tar) is decided from the magic bytes, not the extension."
          },
          "max_entries": {
            "default": 1000,
            "description": "How many entries to list, 1 to 10000 (default 1000), taken in archive order and then sorted by name; any beyond the limit still count in total_entries and set truncated.",
            "type": "integer",
            "minimum": 1,
            "maximum": 10000
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/archives/bundle.zip",
        "max_entries": 1000
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/archives/bundle.zip",
        "kind": "zip",
        "compression": "none",
        "entry_count": 5,
        "total_entries": 5,
        "total_uncompressed_bytes": 159,
        "encrypted": false,
        "truncated": false,
        "entries": [
          {
            "name": "data/",
            "is_directory": true,
            "entry_type": "directory",
            "size": 0,
            "compressed_size": 0,
            "compression": "none",
            "encrypted": false,
            "crc32": "00000000"
          },
          {
            "name": "data/squares.csv",
            "is_directory": false,
            "entry_type": "file",
            "size": 31,
            "compressed_size": 33,
            "compression": "deflate",
            "encrypted": false,
            "crc32": "89761172"
          },
          {
            "name": "docs/",
            "is_directory": true,
            "entry_type": "directory",
            "size": 0,
            "compressed_size": 0,
            "compression": "none",
            "encrypted": false,
            "crc32": "00000000"
          },
          {
            "name": "docs/readme.md",
            "is_directory": false,
            "entry_type": "file",
            "size": 116,
            "compressed_size": 94,
            "compression": "deflate",
            "encrypted": false,
            "crc32": "b6cd27c3"
          },
          {
            "name": "hello.txt",
            "is_directory": false,
            "entry_type": "file",
            "size": 12,
            "compressed_size": 12,
            "compression": "store",
            "encrypted": false,
            "crc32": "af083b2d"
          }
        ]
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "audio-convert",
      "summary": "Transcodes an audio file between WAV, MP3, FLAC, AAC and Opus, with an optional bit rate and sample rate.",
      "rationale": "Re-encoding audio needs ffmpeg AND a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and carrying an ffmpeg per call is not something an agent does inline.",
      "family": "av",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 94000,
      "max_timeout_seconds": 115,
      "max_bytes": 104857600,
      "max_duration_seconds": 3600,
      "input_notes": [
        "With to set to aac, bitrate_kbps is at most 192; a higher value is refused with invalid_input (400)."
      ],
      "endpoint": "https://grist.tools/v1/audio-convert",
      "schema_url": "https://grist.tools/v1/audio-convert/schema",
      "docs_url": "https://grist.tools/docs/audio-convert",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the source file, up to 2048 characters; the first audio stream of a recognised container (MP4/MOV, Matroska/WebM, AVI, Ogg, WAV, FLAC, AIFF or MP3) is converted, and it must last at most 3600 seconds."
          },
          "to": {
            "default": "mp3",
            "description": "Output format: wav (16-bit PCM), mp3 (LAME), flac, aac (ADTS stream) or opus (in an Ogg container); defaults to mp3.",
            "type": "string",
            "enum": [
              "wav",
              "mp3",
              "flac",
              "aac",
              "opus"
            ]
          },
          "bitrate_kbps": {
            "description": "Target bit rate in kbps, lossy formats only (mp3, aac, opus): 8 to 512, and at most 192 for aac.",
            "type": "integer",
            "minimum": 8,
            "maximum": 512
          },
          "sample_rate": {
            "description": "Output sample rate in Hz, one of 8000, 11025, 16000, 22050, 32000, 44100 or 48000; when omitted ffmpeg chooses the rate, normally the source rate.",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/av/convert-beeps.flac",
        "to": "wav"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/av/convert-beeps.flac",
        "bytes": 32086,
        "source_container": "flac",
        "format": "wav",
        "bitrate_kbps": null,
        "sample_rate": null,
        "audio_bytes": 32044,
        "audio_base64": "UklGRiR9AABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQB9AAAA4ADo..."
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "audio-extract",
      "summary": "Extracts the audio track from a video (or any container) and returns it in a chosen audio format.",
      "rationale": "Demuxing and re-encoding an audio track needs ffmpeg AND a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and carrying an ffmpeg per call is not something an agent does inline.",
      "family": "av",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 94000,
      "max_timeout_seconds": 115,
      "max_bytes": 209715200,
      "max_duration_seconds": 3600,
      "endpoint": "https://grist.tools/v1/audio-extract",
      "schema_url": "https://grist.tools/v1/audio-extract/schema",
      "docs_url": "https://grist.tools/docs/audio-extract",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of a video or audio file, up to 2048 characters, in a recognised container (MP4/MOV, Matroska/WebM, AVI, Ogg, WAV, FLAC, AIFF or MP3); the selected audio stream must last at most 3600 seconds."
          },
          "stream": {
            "default": 0,
            "description": "Zero-based index among the audio streams only (0 is the first audio track), 0 to 63; an index the file does not have is refused as unprocessable.",
            "type": "integer",
            "minimum": 0,
            "maximum": 63
          },
          "to": {
            "default": "mp3",
            "description": "Output format for the extracted track: wav (16-bit PCM), mp3 (LAME), flac, aac (ADTS stream) or opus (in an Ogg container); defaults to mp3.",
            "type": "string",
            "enum": [
              "wav",
              "mp3",
              "flac",
              "aac",
              "opus"
            ]
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/av/probe-clip.mov",
        "stream": 0,
        "to": "wav"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/av/probe-clip.mov",
        "bytes": 33850,
        "container": "mp4",
        "stream_index": 0,
        "format": "wav",
        "audio_bytes": 32044,
        "audio_base64": "UklGRiR9AABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQB9AAAA4ADo..."
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "color-palette-extract",
      "summary": "Extracts an image's dominant colours as a small palette (hex, RGB and coverage fraction).",
      "rationale": "Extracting a palette safely needs a real decoder and a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and a byte-reproducible quantiser is not something an agent writes inline.",
      "family": "images",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 60000,
      "max_timeout_seconds": 81,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/color-palette-extract",
      "schema_url": "https://grist.tools/v1/color-palette-extract/schema",
      "docs_url": "https://grist.tools/docs/color-palette-extract",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the image, up to 2048 characters; redirects are followed and the format is detected from the bytes, not the content-type."
          },
          "colors": {
            "default": 5,
            "description": "Maximum number of palette colours to return, 1 to 16, most common first; fewer come back when the image has fewer distinct colour buckets.",
            "type": "integer",
            "minimum": 1,
            "maximum": 16
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/images/palette-bands.png",
        "colors": 5
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/images/palette-bands.png",
        "source_format": "png",
        "sample_size": 64,
        "count": 5,
        "dominant": "#3b4a5a",
        "colors": [
          {
            "hex": "#3b4a5a",
            "rgb": [
              59,
              74,
              90
            ],
            "fraction": 0.375
          },
          {
            "hex": "#d8c9a3",
            "rgb": [
              216,
              201,
              163
            ],
            "fraction": 0.25
          },
          {
            "hex": "#2f8f83",
            "rgb": [
              47,
              143,
              131
            ],
            "fraction": 0.1875
          },
          {
            "hex": "#e0735a",
            "rgb": [
              224,
              115,
              90
            ],
            "fraction": 0.125
          },
          {
            "hex": "#f4efe6",
            "rgb": [
              244,
              239,
              230
            ],
            "fraction": 0.0625
          }
        ]
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "csv-to-json",
      "summary": "Parses a CSV/TSV document into JSON rows, keyed by a header row or as arrays; the delimiter is auto-detected.",
      "rationale": "A correct CSV parser is more than a split on commas (quoted fields, embedded newlines and escaped quotes all bite), and doing it over a URL an agent fetches itself walks past every SSRF control.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 30000,
      "max_timeout_seconds": 51,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/csv-to-json",
      "schema_url": "https://grist.tools/v1/csv-to-json/schema",
      "docs_url": "https://grist.tools/docs/csv-to-json",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "UTF-8 CSV/TSV URL. Missing charset means UTF-8; only utf-8 and utf8 declarations are accepted (case-insensitive, optionally quoted). Unsupported or invalid declarations and NUL bytes return 415; malformed UTF-8 returns 422. A UTF-8 BOM is accepted. Encoding is checked across the whole body."
          },
          "delimiter": {
            "description": "A single field delimiter character such as \",\" or a tab; omitted, it is auto-detected from the first rows among comma, tab, pipe, semicolon and the ASCII record and unit separators, and a line break, quote or BOM given here falls back to a comma.",
            "type": "string",
            "minLength": 1,
            "maxLength": 1
          },
          "header": {
            "default": true,
            "description": "Use the first nonempty record as object keys. Names are trimmed; blank names become column_<position>. Names are made unique left to right using the next unused _2, _3 suffix, including collisions with literal suffixed names. Special names remain data keys. Short rows pad with empty strings; extra fields return 422 csv_extra_fields. With false, preserve actual row widths as string arrays.",
            "type": "boolean"
          },
          "max_rows": {
            "default": 10000,
            "description": "Maximum returned nonempty data rows. One additional nonempty record sets truncated; its width is budgeted but not checked against the header. A truncated success does not validate the entire CSV structure.",
            "type": "integer",
            "minimum": 1,
            "maximum": 100000
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false,
        "description": "All values remain strings. Records whose parsed fields concatenate to whitespace only are skipped, including blank lines, delimiter-only and quoted-empty records. They do not establish headers, consume max_rows or alone set truncated; structural budgets still apply."
      },
      "input_example": {
        "url": "https://grist.tools/samples/documents/garden-plots.csv",
        "header": true
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/documents/garden-plots.csv",
        "delimiter": ",",
        "columns": [
          "plot",
          "holder",
          "crop",
          "area_m2",
          "notes"
        ],
        "rows": [
          {
            "plot": "A1",
            "holder": "Maple Group",
            "crop": "tomatoes",
            "area_m2": "12",
            "notes": "Stakes needed, north side"
          },
          {
            "plot": "A2",
            "holder": "Oak Group",
            "crop": "beans",
            "area_m2": "08",
            "notes": ""
          },
          {
            "plot": "B1",
            "holder": "Willow Group",
            "crop": "herbs, mixed",
            "area_m2": "6",
            "notes": "Shares water with \"B2\""
          },
          {
            "plot": "B2",
            "holder": "Birch Group",
            "crop": "squash",
            "area_m2": "10.50",
            "notes": "Compost added in April"
          }
        ],
        "row_count": 4,
        "truncated": false
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "dns-lookup",
      "summary": "DNS records for a domain (A, AAAA, MX, TXT, NS, CNAME, SOA, CAA) with their TTLs.",
      "rationale": "A DNS query needs the network, so an agent cannot answer it on its own. It is what you run in a loop over a list of domains.",
      "family": "domain",
      "shelf": "web",
      "category": "Verification",
      "price_usd": "0",
      "free": true,
      "timeout_ms": 5000,
      "max_timeout_seconds": 6,
      "max_bytes": 2097152,
      "endpoint": "https://grist.tools/v1/dns-lookup",
      "schema_url": "https://grist.tools/v1/dns-lookup/schema",
      "docs_url": "https://grist.tools/docs/dns-lookup",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "domain": {
            "description": "Fully qualified domain name to resolve, up to 253 characters; it is trimmed, lowercased and stripped of trailing dots, and IP literals, single-label names and private suffixes such as .local or .internal are refused.",
            "type": "string",
            "minLength": 1,
            "maxLength": 253
          },
          "types": {
            "description": "Record types to query, from a, aaaa, mx, txt, ns, cname, soa and caa in either case; duplicates collapse to one query per type, answers come back in that fixed order, and the default is all eight.",
            "minItems": 1,
            "maxItems": 16,
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "a",
                "aaaa",
                "mx",
                "txt",
                "ns",
                "cname",
                "soa",
                "caa",
                "A",
                "AAAA",
                "MX",
                "TXT",
                "NS",
                "CNAME",
                "SOA",
                "CAA"
              ]
            }
          }
        },
        "required": [
          "domain"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "domain": "example.com",
        "types": [
          "a",
          "mx"
        ]
      },
      "output_example": {
        "domain": "example.com",
        "types_requested": [
          "a",
          "mx"
        ],
        "records": [
          {
            "type": "a",
            "values": [
              {
                "address": "93.184.215.14",
                "ttl": 3600
              }
            ],
            "truncated": false,
            "resolution_error": null
          },
          {
            "type": "mx",
            "values": [
              {
                "exchange": "mail.example.com",
                "priority": 10
              }
            ],
            "truncated": false,
            "resolution_error": null
          }
        ],
        "queried_at": "2026-08-26T18:00:00.000Z"
      },
      "example_kind": "illustrative",
      "example_note": "This example is illustrative: it shows the exact shape the handler returns, but it was not produced by a reproducible call, because the real answer depends on live network data that changes over time.",
      "errors": [
        "invalid_input",
        "unreachable_target",
        "upstream_timeout"
      ]
    },
    {
      "name": "docx-to-markdown",
      "summary": "Converts a Word .docx to Markdown, surfacing anything that did not translate as warnings.",
      "rationale": "A faithful docx→Markdown conversion needs a real OOXML engine plus a hardened fetch: an agent that fetches the URL itself walks past every SSRF control, and carrying mammoth per call is not something an agent does inline.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 40000,
      "max_timeout_seconds": 61,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/docx-to-markdown",
      "schema_url": "https://grist.tools/v1/docx-to-markdown/schema",
      "docs_url": "https://grist.tools/docs/docx-to-markdown",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the .docx, up to 2048 characters; redirects are followed, the body must be a zip, and mammoth must find a Word document in it."
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/documents/garden-handbook.docx"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/documents/garden-handbook.docx",
        "chars": 341,
        "warnings": [],
        "markdown": "# Community Garden Handbook\n\nPlots are assigned for **one season** and renewed each spring.\n\n## Watering\n\n-   Water early in the morning.\n-   Use the rain barrels first.\n-   Log each watering on the shed board.\n\n## Tools\n\nReturn every tool to the shed _before sunset_.\n\nThe seasonal calendar lives on [the garden site](https://grist.tools/)."
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "email-validate",
      "summary": "Validates an email address: syntax, MX records for its domain, and whether it is a disposable or role account.",
      "rationale": "The MX check needs a resolver an agent may not have, and the disposable/role assessment is a curated dataset and rule set it would otherwise have to build and keep current.",
      "family": "domain",
      "shelf": "web",
      "category": "Verification",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 5000,
      "max_timeout_seconds": 26,
      "max_bytes": 2097152,
      "endpoint": "https://grist.tools/v1/email-validate",
      "schema_url": "https://grist.tools/v1/email-validate/schema",
      "docs_url": "https://grist.tools/docs/email-validate",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "minLength": 1,
            "maxLength": 320,
            "description": "The address to assess, up to 320 characters; it is trimmed, and a malformed address is answered with syntax_valid false rather than rejected."
          },
          "check_mx": {
            "default": true,
            "description": "Whether to resolve MX records for the domain; when true, a domain with no MX makes valid false, and false skips every network lookup.",
            "type": "boolean"
          },
          "check_disposable": {
            "default": true,
            "description": "Whether to match the domain against the bundled disposable-provider list; when true, a disposable domain makes valid false.",
            "type": "boolean"
          }
        },
        "required": [
          "email"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "email": "support@grist.tools",
        "check_mx": false
      },
      "output_example": {
        "email": "support@grist.tools",
        "valid": true,
        "syntax_valid": true,
        "mx_found": false,
        "mx_records": [],
        "is_disposable": false,
        "is_role_account": true
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "unreachable_target",
        "upstream_timeout",
        "internal"
      ]
    },
    {
      "name": "epub-to-text",
      "summary": "Extracts an EPUB’s reading-order text and Dublin Core metadata, chapter by chapter.",
      "rationale": "Reading an EPUB means walking a zip, its container, its OPF spine and each XHTML chapter through a hardened fetch. An agent doing that inline walks past every SSRF control, and it is fiddly enough to be worth a paid endpoint.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 45000,
      "max_timeout_seconds": 66,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/epub-to-text",
      "schema_url": "https://grist.tools/v1/epub-to-text/schema",
      "docs_url": "https://grist.tools/docs/epub-to-text",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the .epub, up to 2048 characters; redirects are followed and the body must be a zip whose META-INF/container.xml points at an OPF package."
          },
          "max_chapters": {
            "default": 500,
            "description": "How many leading XHTML/HTML spine chapters to extract, 1 to 5000; a shorter book is not an error, and a longer one is cut and reported as truncated.",
            "type": "integer",
            "minimum": 1,
            "maximum": 5000
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/documents/seed-saving.epub",
        "max_chapters": 500
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/documents/seed-saving.epub",
        "metadata": {
          "title": "A Short Guide to Seed Saving",
          "creator": "Grist Samples",
          "language": "en"
        },
        "chapter_count": 3,
        "extracted_chapters": 3,
        "truncated": false,
        "chars": 441,
        "chapters": [
          {
            "href": "OEBPS/ch1.xhtml",
            "text": "Why Save Seeds\n\nSaving seeds keeps a variety that grows well in your own soil.\n\nIt also costs nothing and makes next spring easier to plan."
          },
          {
            "href": "OEBPS/ch2.xhtml",
            "text": "Choosing Plants\n\nPick the healthiest plants and let a few fruits ripen fully.\n\nBeans and peas are the easiest to start with.\n\nTomatoes need their seeds rinsed and dried."
          },
          {
            "href": "OEBPS/ch3.xhtml",
            "text": "Drying and Storing\n\nDry seeds on paper for a week, away from direct sun.\n\nStore them in labelled envelopes in a cool, dark place."
          }
        ],
        "text": "Why Save Seeds\n\nSaving seeds keeps a variety that grows well in your own soil.\n\nIt also costs nothing and makes next spring easier to plan.\n\nChoosing Plants\n\nPick the healthiest plants and let a few fruits ripen fully.\n\nBeans and peas are the easiest to start with.\n\nTomatoes need their seeds rinsed and dried.\n\nDrying and Storing\n\nDry seeds on paper for a week, away from direct sun.\n\nStore them in labelled envelopes in a cool, dark place."
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "favicon-extract",
      "summary": "Discovers a page's favicons and returns the best one decoded to a normalised PNG with its size.",
      "rationale": "It fans out over URLs a page author wrote and must decode several icon formats: an agent that does this itself walks past every SSRF control and becomes a request amplifier, and hand-rolling ICO/SVG decoding is not inline work.",
      "family": "images",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 45000,
      "max_timeout_seconds": 66,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/favicon-extract",
      "schema_url": "https://grist.tools/v1/favicon-extract/schema",
      "docs_url": "https://grist.tools/docs/favicon-extract",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the web page whose icon <link> tags (rel icon, shortcut, apple-touch-icon or mask-icon) are read, up to 2048 characters; /favicon.ico on its origin is added as a last candidate and icons are only fetched from the same registrable domain."
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/web/fieldnotes/index.html"
      },
      "output_example": {
        "page_url": "https://grist.tools/samples/web/fieldnotes/index.html",
        "icons": [
          {
            "url": "https://grist.tools/samples/web/fieldnotes/icon-32.png",
            "rel": "icon",
            "type": "image/png",
            "sizes": "32x32"
          },
          {
            "url": "https://grist.tools/samples/web/fieldnotes/icon.svg",
            "rel": "icon",
            "type": "image/svg+xml",
            "sizes": null
          },
          {
            "url": "https://grist.tools/favicon.ico",
            "rel": "icon",
            "type": null,
            "sizes": null
          }
        ],
        "selected": {
          "url": "https://grist.tools/samples/web/fieldnotes/icon-32.png",
          "source_format": "png",
          "width": 32,
          "height": 32,
          "bytes": 106,
          "image_base64": "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAACXBIWXMAAAsSAAALEgHS3X78AAAAMUlEQVRIx2PQjw2gKWIYtWDUgiFswaf3z0hCoxaMWjBqwagFoxYMTwtGq8xRC0aQBQBMN1tMlMrjbQAAAABJRU5ErkJggg=="
        }
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "feed-discover",
      "summary": "Fetches a web page and returns the RSS, Atom and JSON feeds it declares in its <head>, as absolute URLs with their type and title.",
      "rationale": "It needs the network, and the feed an agent wants is declared in the page it already has as a <link rel=\"alternate\"> -- one guarded fetch and a DOM query returns it, where feeding the whole page to a model costs far more in tokens and still guesses.",
      "family": "feeds",
      "shelf": "web",
      "category": "Data",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 8000,
      "max_timeout_seconds": 29,
      "max_bytes": 5242880,
      "endpoint": "https://grist.tools/v1/feed-discover",
      "schema_url": "https://grist.tools/v1/feed-discover/schema",
      "docs_url": "https://grist.tools/docs/feed-discover",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the HTML or XHTML page whose <link rel=\"alternate\"> or rel=\"feed\" declarations with an RSS, Atom or JSON Feed media type are read, up to 2048 characters; up to 5 redirects are followed and no feed paths are probed."
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/web/fieldnotes/index.html"
      },
      "output_example": {
        "url": "https://grist.tools/samples/web/fieldnotes/index.html",
        "source_url": "https://grist.tools/samples/web/fieldnotes/index.html",
        "final_url": "https://grist.tools/samples/web/fieldnotes/index.html",
        "http_status": 200,
        "feeds": [
          {
            "url": "https://grist.tools/samples/web/fieldnotes/feed.xml",
            "type": "rss",
            "title": "Field Notes (RSS)",
            "rel": "alternate"
          },
          {
            "url": "https://grist.tools/samples/web/fieldnotes/atom.xml",
            "type": "atom",
            "title": "Field Notes (Atom)",
            "rel": "alternate"
          }
        ],
        "feeds_total": 2,
        "feeds_truncated": false,
        "fetched_at": "2026-09-01T12:00:00.000Z"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_status",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "internal"
      ]
    },
    {
      "name": "file-type-detect",
      "summary": "Detects a remote file's true type from its magic bytes, returning the canonical extension and media type.",
      "rationale": "Trusting a URL extension or a server content-type is exactly how an agent mislabels a payload; deciding the type from the bytes needs a signature database and a hardened fetch an agent should not run inline.",
      "family": "archives",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 30000,
      "max_timeout_seconds": 51,
      "max_bytes": 33554432,
      "endpoint": "https://grist.tools/v1/file-type-detect",
      "schema_url": "https://grist.tools/v1/file-type-detect/schema",
      "docs_url": "https://grist.tools/docs/file-type-detect",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the file, up to 2048 characters; redirects are followed, the whole body (at most 32 MB) is downloaded, and the type comes from its magic bytes, ignoring the extension and content-type header."
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/archives/bundle.zip"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/archives/bundle.zip",
        "bytes": 639,
        "detected": true,
        "ext": "zip",
        "mime": "application/zip"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "html-clean-text",
      "summary": "Fetches a web page and returns the article as clean text and markdown, with its declared metadata.",
      "rationale": "It needs the network and a real DOM: boilerplate removal, legacy charset decoding and HTML-to-markdown are three heavy libraries an agent does not carry, and feeding raw HTML to a model instead costs far more in tokens than this call costs.",
      "family": "fetch",
      "shelf": "web",
      "category": "Data",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 12000,
      "max_timeout_seconds": 33,
      "max_bytes": 1048576,
      "endpoint": "https://grist.tools/v1/html-clean-text",
      "schema_url": "https://grist.tools/v1/html-clean-text/schema",
      "docs_url": "https://grist.tools/docs/html-clean-text",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "description": "URL of an HTML, XHTML or plain-text page to fetch, up to 2048 characters; redirects are followed, the body is capped at 1 MB, and exactly one of url or html is required.",
            "type": "string",
            "maxLength": 2048,
            "format": "uri"
          },
          "html": {
            "description": "Inline HTML source to clean instead of fetching, up to 262,144 characters; binary content such as PDF or JSON is refused.",
            "type": "string",
            "minLength": 1,
            "maxLength": 262144
          },
          "base_url": {
            "description": "Base URL for resolving relative links in the html input; not allowed together with url, where the final fetched URL is the base.",
            "type": "string",
            "maxLength": 2048,
            "format": "uri"
          },
          "include_links": {
            "default": false,
            "description": "Also return the links inside the extracted content, each with its text, rel and same-host flag, resolved against the fetched URL or base_url when there is one, else kept as authored.",
            "type": "boolean"
          },
          "readability": {
            "default": true,
            "description": "Run Readability to keep only the main article; false, or an article Readability cannot find, converts the whole body, navigation and footer included.",
            "type": "boolean"
          },
          "max_chars": {
            "default": 50000,
            "description": "Maximum length of text and markdown, 200 to 200,000 Unicode code points (default 50,000); a longer result is cut and flagged as truncated.",
            "type": "integer",
            "minimum": 200,
            "maximum": 200000
          }
        },
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/web/render-article.html",
        "include_links": true,
        "readability": true,
        "max_chars": 2000
      },
      "output_example": {
        "requested_url": "https://grist.tools/samples/web/render-article.html",
        "source_url": "https://grist.tools/samples/web/render-article.html",
        "final_url": "https://grist.tools/samples/web/render-article.html",
        "content_type": "text/html",
        "charset": "utf-8",
        "http_status": 200,
        "title": "Measuring Rainfall with a Simple Gauge",
        "byline": "Sample Author",
        "excerpt": "A short guide to reading a cylinder rain gauge at the same hour every day.",
        "site_name": "Grist Samples",
        "lang": "en",
        "lang_source": "html_lang",
        "text": "A cylinder rain gauge is a clear tube with a scale printed on the side. Rain falls through a funnel at the top and collects in the tube, where the water level shows how much fell since the last reading.\nThe most useful habit is consistency. Read the gauge at the same hour every day, ideally in the morning, so that each number covers one full day.\nPlacing the gauge\nPut the gauge in an open spot, away from walls, trees and roofs. A good rule is to keep it at least twice as far from any obstacle as the obstacle is tall.\nMount it level, so the scale reads true.\nKeep the funnel clear of leaves and insects.\nEmpty the tube right after each reading.\nReading the scale\nBend down until your eye is level with the water. The surface curves slightly where it meets the tube; read the number at the bottom of that curve.\nWrite the reading in a notebook with the date and time. A printable schedule helps when several people share the job.",
        "markdown": "A cylinder rain gauge is a clear tube with a scale printed on the side. Rain falls through a funnel at the top and collects in the tube, where the water level shows how much fell since the last reading.\n\nThe most useful habit is consistency. Read the gauge at the same hour every day, ideally in the morning, so that each number covers one full day.\n\n## Placing the gauge\n\nPut the gauge in an open spot, away from walls, trees and roofs. A good rule is to keep it at least twice as far from any obstacle as the obstacle is tall.\n\n-   Mount it level, so the scale reads true.\n-   Keep the funnel clear of leaves and insects.\n-   Empty the tube right after each reading.\n\n## Reading the scale\n\nBend down until your eye is level with the water. The surface curves slightly where it meets the tube; read the number at the bottom of that curve.\n\nWrite the reading in a notebook with the date and time. A [printable schedule](https://grist.tools/samples/web/render-print.html) helps when several people share the job.",
        "word_count": 178,
        "char_count": 933,
        "truncated": false,
        "readability_applied": true,
        "links": [
          {
            "href": "https://grist.tools/samples/web/render-print.html",
            "text": "printable schedule",
            "rel": null,
            "is_internal": true
          }
        ],
        "link_count": 1,
        "fetched_at": "2026-09-01T12:00:00.000Z"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_status",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "html-to-pdf",
      "summary": "Renders a web page or an inline HTML string to a PDF, returned base64-encoded with its page count.",
      "rationale": "It needs a headless browser AND a hardened fetch: an agent that renders a URL itself walks the browser straight past every SSRF control, and carrying a Chromium is not something an agent does per call.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 12000,
      "max_timeout_seconds": 33,
      "max_bytes": 5242880,
      "endpoint": "https://grist.tools/v1/html-to-pdf",
      "schema_url": "https://grist.tools/v1/html-to-pdf/schema",
      "docs_url": "https://grist.tools/docs/html-to-pdf",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "description": "Absolute http(s) URL of an HTML or XHTML page to fetch and render, up to 2048 characters; redirects are followed, the body is capped at 5 MB, and exactly one of url or html is required.",
            "type": "string",
            "maxLength": 2048,
            "format": "uri"
          },
          "html": {
            "type": "string",
            "minLength": 1,
            "maxLength": 262144,
            "description": "Self-contained HTML. Inline PNG data images are supported; external images, stylesheets and fonts are blocked. JavaScript is disabled."
          },
          "format": {
            "default": "A4",
            "description": "Paper size of every page, A4 by default.",
            "type": "string",
            "enum": [
              "A3",
              "A4",
              "A5",
              "Letter",
              "Legal",
              "Tabloid"
            ]
          },
          "landscape": {
            "default": false,
            "description": "Print in landscape orientation instead of portrait.",
            "type": "boolean"
          }
        },
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/web/render-print.html",
        "format": "A4",
        "landscape": false
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/web/render-print.html",
        "pages": 1,
        "bytes": 38404,
        "format": "A4",
        "landscape": false,
        "pdf_base64": "JVBERi0xLjcKJYGBgYEKCjYgMCBvYmoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUK...",
        "rendered_at": "2026-09-01T12:00:00.000Z"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "http-headers",
      "summary": "Returns the response headers, final status, redirect chain and a security-header grade for a URL, without downloading the body.",
      "rationale": "It needs a hardened HTTP client -- an agent that fetches the URL itself can walk a redirect into a private address -- and the security grade is a deterministic, documented rubric it would otherwise have to build and maintain.",
      "family": "fetch",
      "shelf": "web",
      "category": "Data",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 8000,
      "max_timeout_seconds": 29,
      "max_bytes": 65536,
      "endpoint": "https://grist.tools/v1/http-headers",
      "schema_url": "https://grist.tools/v1/http-headers/schema",
      "docs_url": "https://grist.tools/docs/http-headers",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL to request, up to 2048 characters; up to five redirects are followed and every hop goes through the SSRF guard."
          },
          "method": {
            "default": "HEAD",
            "description": "HTTP method for the request, HEAD by default; GET is for servers that mishandle HEAD, and its body is still never read.",
            "type": "string",
            "enum": [
              "HEAD",
              "GET"
            ]
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://example.com/",
        "method": "HEAD"
      },
      "output_example": {
        "url": "https://example.com/",
        "source_url": "https://example.com/",
        "final_url": "https://example.com/",
        "redirect_count": 0,
        "http_status": 200,
        "headers": {
          "content-security-policy": "default-src 'self'",
          "content-type": "text/html; charset=utf-8",
          "cross-origin-opener-policy": "same-origin",
          "cross-origin-resource-policy": "same-origin",
          "permissions-policy": "geolocation=()",
          "referrer-policy": "strict-origin-when-cross-origin",
          "strict-transport-security": "max-age=63072000; includeSubDomains; preload",
          "x-content-type-options": "nosniff",
          "x-frame-options": "DENY"
        },
        "security": {
          "hsts": {
            "present": true,
            "max_age": 63072000,
            "include_subdomains": true,
            "preload": true
          },
          "csp_present": true,
          "x_content_type_options": "nosniff",
          "x_frame_options": "DENY",
          "referrer_policy": "strict-origin-when-cross-origin",
          "permissions_policy": "geolocation=()",
          "cross_origin_opener_policy": "same-origin",
          "cross_origin_resource_policy": "same-origin"
        },
        "security_grade": "A",
        "missing_security_headers": [],
        "fetched_at": "2026-08-26T18:00:00.000Z"
      },
      "example_kind": "illustrative",
      "example_note": "This example is illustrative: it shows the exact shape the handler returns, but it was not produced by a reproducible call, because the real answer depends on live network data that changes over time.",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "internal"
      ]
    },
    {
      "name": "image-compress",
      "summary": "Re-encodes an image in its own format at a lower quality to shrink the file, keeping its dimensions.",
      "rationale": "Compressing safely needs a real codec and a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and the output must be byte-reproducible, which an ad-hoc re-encode is not.",
      "family": "images",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 60000,
      "max_timeout_seconds": 81,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/image-compress",
      "schema_url": "https://grist.tools/v1/image-compress/schema",
      "docs_url": "https://grist.tools/docs/image-compress",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of a JPEG, PNG, WebP or GIF image, up to 2048 characters; redirects are followed and the output keeps the source format and dimensions."
          },
          "quality": {
            "default": 75,
            "description": "Encoder quality from 1 to 100, lower gives a smaller file; drives JPEG and WebP re-compression and PNG palette quantisation, and is ignored for GIF.",
            "type": "integer",
            "minimum": 1,
            "maximum": 100
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/images/compress-scene.jpg",
        "quality": 60
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/images/compress-scene.jpg",
        "format": "jpeg",
        "width": 96,
        "height": 64,
        "source_bytes": 1836,
        "bytes": 758,
        "saved_bytes": 1078,
        "ratio": 0.4129,
        "image_base64": "/9j/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZG..."
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "image-convert",
      "summary": "Transcodes an image to PNG, JPEG or WebP at a chosen quality, returned base64-encoded.",
      "rationale": "Transcoding safely needs a real codec and a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and the output must be byte-reproducible, which an ad-hoc re-encode is not.",
      "family": "images",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 60000,
      "max_timeout_seconds": 81,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/image-convert",
      "schema_url": "https://grist.tools/v1/image-convert/schema",
      "docs_url": "https://grist.tools/docs/image-convert",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the source image, up to 2048 characters; redirects are followed and the source format is detected from the bytes."
          },
          "format": {
            "type": "string",
            "enum": [
              "png",
              "jpeg",
              "webp"
            ],
            "description": "Target format to encode to, png, jpeg or webp; the pixel dimensions are kept and metadata is dropped."
          },
          "quality": {
            "default": 80,
            "description": "Encoder quality from 1 to 100 for jpeg and webp output; ignored for png, which is always lossless.",
            "type": "integer",
            "minimum": 1,
            "maximum": 100
          }
        },
        "required": [
          "url",
          "format"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/images/convert-gradient.png",
        "format": "webp",
        "quality": 80
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/images/convert-gradient.png",
        "source_format": "png",
        "format": "webp",
        "mime_type": "image/webp",
        "width": 32,
        "height": 32,
        "bytes": 156,
        "image_base64": "UklGRpQAAABXRUJQVlA4IIgAAAAQBQCdASogACAALk02m02hJCQkBABMS2AE6ZiX..."
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "image-metadata",
      "summary": "Reads an image's EXIF/ICC/IPTC/XMP metadata and can return a copy with all metadata stripped.",
      "rationale": "Reading and stripping image metadata safely needs a real decoder and a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and a strip must be byte-reproducible, which an ad-hoc re-encode is not.",
      "family": "images",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 30000,
      "max_timeout_seconds": 51,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/image-metadata",
      "schema_url": "https://grist.tools/v1/image-metadata/schema",
      "docs_url": "https://grist.tools/docs/image-metadata",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the image to inspect, up to 2048 characters; redirects are followed and the format is detected from the bytes."
          },
          "strip": {
            "default": false,
            "description": "When true, also return a same-format copy re-encoded with every metadata block removed (lossy sources at quality 80); allowed for JPEG, PNG, WebP, TIFF and GIF.",
            "type": "boolean"
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/images/metadata-exif.jpg",
        "strip": true
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/images/metadata-exif.jpg",
        "format": "jpeg",
        "width": 32,
        "height": 24,
        "channels": 3,
        "space": "srgb",
        "depth": "uchar",
        "has_alpha": false,
        "has_profile": true,
        "orientation": 6,
        "density": 72,
        "is_progressive": false,
        "metadata": {
          "exif_bytes": 186,
          "icc_bytes": 480,
          "iptc_bytes": null,
          "xmp_bytes": null
        },
        "bytes": 1024,
        "stripped": {
          "format": "jpeg",
          "bytes": 333,
          "image_base64": "/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsj..."
        }
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "image-probe",
      "summary": "Reports an image's format and pixel dimensions from its header, without decoding the pixels.",
      "rationale": "Reading an image header safely needs a real decoder and a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and carrying an image library per call is not something an agent does inline.",
      "family": "images",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 20000,
      "max_timeout_seconds": 41,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/image-probe",
      "schema_url": "https://grist.tools/v1/image-probe/schema",
      "docs_url": "https://grist.tools/docs/image-probe",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the image, up to 2048 characters; redirects are followed, the bytes must be jpeg, png, gif, webp, tiff, avif, heic or svg by signature, and only the header is parsed."
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/images/tiles-64x40.png"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/images/tiles-64x40.png",
        "format": "png",
        "width": 64,
        "height": 40,
        "channels": 4,
        "space": "srgb",
        "has_alpha": true,
        "bytes": 10348
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "image-resize",
      "summary": "Resizes an image to a target width/height under a chosen fit, returned base64-encoded.",
      "rationale": "Resizing safely needs a real decoder and a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and the output must be byte-reproducible, which an ad-hoc resample is not.",
      "family": "images",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 60000,
      "max_timeout_seconds": 81,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/image-resize",
      "schema_url": "https://grist.tools/v1/image-resize/schema",
      "docs_url": "https://grist.tools/docs/image-resize",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the source image, up to 2048 characters; redirects are followed, the bytes must be jpeg, png, gif, webp, tiff, avif, heic or svg by signature, and at most 40 megapixels."
          },
          "width": {
            "description": "Target width in pixels, 1 to 20000; at least one of width or height is required, and a missing side follows the aspect ratio (except under fill).",
            "type": "integer",
            "minimum": 1,
            "maximum": 20000
          },
          "height": {
            "description": "Target height in pixels, 1 to 20000; at least one of width or height is required, and a missing side follows the aspect ratio (except under fill).",
            "type": "integer",
            "minimum": 1,
            "maximum": 20000
          },
          "fit": {
            "default": "inside",
            "description": "How the image fills the target box, as in sharp: inside fits within keeping the ratio (default), cover crops from the centre, contain pads to the full box, fill stretches, outside covers the box without cropping.",
            "type": "string",
            "enum": [
              "cover",
              "contain",
              "fill",
              "inside",
              "outside"
            ]
          },
          "format": {
            "description": "Output format; when omitted the source format is kept if it is png, jpeg or webp, otherwise the output is PNG.",
            "type": "string",
            "enum": [
              "png",
              "jpeg",
              "webp"
            ]
          },
          "quality": {
            "default": 80,
            "description": "Encoder quality from 1 to 100, default 80, used by the lossy jpeg and webp encoders and ignored for png.",
            "type": "integer",
            "minimum": 1,
            "maximum": 100
          },
          "allow_enlarge": {
            "default": false,
            "description": "When true the image may be scaled up past its native size; default false keeps each side at most its source size.",
            "type": "boolean"
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/images/tiles-64x40.png",
        "width": 32,
        "fit": "inside"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/images/tiles-64x40.png",
        "source_format": "png",
        "source_width": 64,
        "source_height": 40,
        "format": "png",
        "mime_type": "image/png",
        "fit": "inside",
        "width": 32,
        "height": 20,
        "bytes": 779,
        "image_base64": "iVBORw0KGgoAAAANSUhEUgAAACAAAAAUCAYAAADskT9PAAAACXBIWXMAAAsSAAAL..."
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "ip-info",
      "summary": "Reverse DNS (PTR) and network ownership for an IP address or a domain.",
      "rationale": "A reverse lookup needs the network, and the ownership data is a versioned dataset an agent does not carry. It is what you run over a list of addresses from a log.",
      "family": "domain",
      "shelf": "web",
      "category": "Verification",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 5000,
      "max_timeout_seconds": 26,
      "max_bytes": 2097152,
      "endpoint": "https://grist.tools/v1/ip-info",
      "schema_url": "https://grist.tools/v1/ip-info/schema",
      "docs_url": "https://grist.tools/docs/ip-info",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "ip": {
            "description": "A public IPv4 or IPv6 address to look up; private, loopback and reserved addresses are refused. Give exactly one of ip or domain.",
            "type": "string",
            "minLength": 1
          },
          "domain": {
            "description": "A public fully qualified domain name, resolved to its first address, which is then looked up; the call is refused if the name resolves to any private address. Give exactly one of ip or domain.",
            "type": "string",
            "minLength": 1
          }
        },
        "additionalProperties": false
      },
      "input_example": {
        "ip": "8.8.8.8"
      },
      "output_example": {
        "query": "8.8.8.8",
        "ip": "8.8.8.8",
        "ptr": "dns.google",
        "asn": 15169,
        "asn_org": "GOOGLE",
        "country": "US"
      },
      "example_kind": "illustrative",
      "example_note": "This example is illustrative: it shows the exact shape the handler returns, but it was not produced by a reproducible call, because the real answer depends on live network data that changes over time.",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "internal"
      ]
    },
    {
      "name": "json-to-csv",
      "summary": "Renders JSON objects as raw CSV with no spreadsheet formula protection; columns are inferred or given, nested values are JSON-encoded.",
      "rationale": "Correct CSV output is quoting and escaping to RFC-4180, not a join on commas, and the column set has to be reconciled across rows with different keys, fiddly enough that paying for it beats hand-rolling it per call. Headers and string values preserve formula prefixes (=, +, -, @), tabs and carriage returns without added apostrophes or trimming. CSV quoting escapes delimiters, quotes and newlines but does not neutralize spreadsheet formulas. Use spreadsheet-specific import controls for untrusted data. CRLF separates records.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 30000,
      "max_timeout_seconds": 51,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/json-to-csv",
      "schema_url": "https://grist.tools/v1/json-to-csv/schema",
      "docs_url": "https://grist.tools/docs/json-to-csv",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of a UTF-8 JSON document (a leading BOM is allowed) that is an array of objects or a single object, up to 2048 characters; redirects are followed."
          },
          "columns": {
            "description": "Selects columns in exactly the supplied order; a missing key produces an empty field, except an absent __proto__ column renders {}. When omitted, visits only rows retained by max_rows, in input order, appending each unseen key once. Within each row, JavaScript enumerates canonical array-index keys (0 through 4294967294) numerically ascending, then other string keys in insertion order. Names such as 01, -1 and 4294967295 are ordinary string keys. New keys from later rows are appended, not globally sorted; textual JSON key order is not preserved for array-index keys.",
            "minItems": 1,
            "maxItems": 1000,
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255
            }
          },
          "max_rows": {
            "default": 10000,
            "description": "Maximum rows rendered, 1 to 100000, default 10000; later rows are dropped and truncated is set, and inferred columns come only from the kept rows.",
            "type": "integer",
            "minimum": 1,
            "maximum": 100000
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/documents/garden-harvest.json"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/documents/garden-harvest.json",
        "columns": [
          "week",
          "crop",
          "kg",
          "plots",
          "note"
        ],
        "csv": "week,crop,kg,plots,note\r\n1,radish,2.5,\"[\"\"A1\"\",\"\"B2\"\"]\",\r\n1,lettuce,4,,\"first cut, outer leaves only\"\r\n2,peas,3.25,\"[\"\"A2\"\"]\",",
        "row_count": 3,
        "truncated": false
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "link-extract",
      "summary": "Fetches a web page and returns every link on it, resolved to absolute, each tagged with its rel, a nofollow flag and whether it stays on the same site.",
      "rationale": "It needs the network and a real DOM: resolving relative and protocol-relative hrefs against a possible <base>, classifying internal versus external, and reading rel/nofollow correctly is fiddly to get right, and feeding raw HTML to a model to do it costs far more in tokens than this call costs.",
      "family": "fetch",
      "shelf": "web",
      "category": "Data",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 8000,
      "max_timeout_seconds": 29,
      "max_bytes": 5242880,
      "endpoint": "https://grist.tools/v1/link-extract",
      "schema_url": "https://grist.tools/v1/link-extract/schema",
      "docs_url": "https://grist.tools/docs/link-extract",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the HTML or XHTML page to read, up to 2048 characters; up to 5 redirects are followed and links resolve against the final URL or its <base href>."
          },
          "internal_only": {
            "default": false,
            "description": "When true, return only links whose host equals the host of the fetched page (the final redirect hop); the totals then count only those links.",
            "type": "boolean"
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/web/fieldnotes/index.html",
        "internal_only": false
      },
      "output_example": {
        "url": "https://grist.tools/samples/web/fieldnotes/index.html",
        "source_url": "https://grist.tools/samples/web/fieldnotes/index.html",
        "final_url": "https://grist.tools/samples/web/fieldnotes/index.html",
        "http_status": 200,
        "base_url": "https://grist.tools/samples/web/fieldnotes/index.html",
        "links": [
          {
            "href": "https://grist.tools/samples/web/fieldnotes/index.html",
            "text": "Home",
            "rel": null,
            "nofollow": false,
            "internal": true
          },
          {
            "href": "https://grist.tools/samples/web/fieldnotes/about.html",
            "text": "About",
            "rel": null,
            "nofollow": false,
            "internal": true
          },
          {
            "href": "https://grist.tools/samples/web/fieldnotes/feed.xml",
            "text": "Subscribe",
            "rel": "alternate",
            "nofollow": false,
            "internal": true
          },
          {
            "href": "https://grist.tools/samples/web/fieldnotes/notes/first-frost.html",
            "text": "The first frost of the season",
            "rel": null,
            "nofollow": false,
            "internal": true
          },
          {
            "href": "https://grist.tools/samples/web/fieldnotes/notes/rain-gauge.html",
            "text": "Reading a rain gauge by hand",
            "rel": null,
            "nofollow": false,
            "internal": true
          },
          {
            "href": "https://example.org/glossary",
            "text": "an outside glossary",
            "rel": "nofollow noopener",
            "nofollow": true,
            "internal": false
          },
          {
            "href": "https://grist.tools/samples/web/fieldnotes/sitemap-index.xml",
            "text": "Sitemap",
            "rel": null,
            "nofollow": false,
            "internal": true
          }
        ],
        "links_total": 7,
        "links_returned": 7,
        "links_truncated": false,
        "fetched_at": "2026-09-01T12:00:00.000Z"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_status",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "markdown-to-pdf",
      "summary": "Renders a Markdown document to a PDF, returned base64-encoded with its page count.",
      "rationale": "Turning styled Markdown into a paginated PDF needs a real layout engine AND a hardened fetch: an agent that renders a URL itself walks the browser straight past every SSRF control, and carrying a Chromium is not something an agent does per call.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 12000,
      "max_timeout_seconds": 33,
      "max_bytes": 2097152,
      "endpoint": "https://grist.tools/v1/markdown-to-pdf",
      "schema_url": "https://grist.tools/v1/markdown-to-pdf/schema",
      "docs_url": "https://grist.tools/docs/markdown-to-pdf",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "description": "Absolute http(s) URL of a UTF-8 Markdown text file to fetch and render, up to 2048 characters; redirects are followed, the body is capped at 2 MB, and exactly one of url or markdown is required.",
            "type": "string",
            "maxLength": 2048,
            "format": "uri"
          },
          "markdown": {
            "type": "string",
            "minLength": 1,
            "maxLength": 262144,
            "description": "Markdown with escaped raw HTML. PNG/JPEG/GIF/WebP data-image syntax is accepted; SVG data images and external image loading are blocked."
          },
          "format": {
            "default": "A4",
            "description": "Paper size of every page, A4 by default.",
            "type": "string",
            "enum": [
              "A3",
              "A4",
              "A5",
              "Letter",
              "Legal",
              "Tabloid"
            ]
          },
          "landscape": {
            "default": false,
            "description": "Print in landscape orientation instead of portrait.",
            "type": "boolean"
          }
        },
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/documents/render-notes.md",
        "format": "A4",
        "landscape": false
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/documents/render-notes.md",
        "pages": 1,
        "bytes": 69844,
        "format": "A4",
        "landscape": false,
        "pdf_base64": "JVBERi0xLjcKJYGBgYEKCjkgMCBvYmoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUK...",
        "rendered_at": "2026-09-01T12:00:00.000Z"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "media-probe",
      "summary": "Reads a media file's container, duration, bit rate and per-stream codec/resolution without decoding it.",
      "rationale": "Probing a media file needs the ffprobe binary AND a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and carrying an ffmpeg per call is not something an agent does inline.",
      "family": "av",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 30000,
      "max_timeout_seconds": 51,
      "max_bytes": 104857600,
      "endpoint": "https://grist.tools/v1/media-probe",
      "schema_url": "https://grist.tools/v1/media-probe/schema",
      "docs_url": "https://grist.tools/docs/media-probe",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the audio or video file, up to 2048 characters, in a recognised container (MP4/MOV, Matroska/WebM, AVI, Ogg, WAV, FLAC, AIFF or MP3); ffprobe reads its container index and nothing is decoded."
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/av/probe-clip.mov"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/av/probe-clip.mov",
        "bytes": 33850,
        "container": "mp4",
        "format_name": "mov,mp4,m4a,3gp,3g2,mj2",
        "duration_seconds": 2,
        "bit_rate": 135400,
        "streams": [
          {
            "index": 0,
            "type": "video",
            "codec": "png",
            "codec_long_name": "PNG (Portable Network Graphics) image",
            "width": 64,
            "height": 36,
            "frame_rate": 2,
            "sample_rate": null,
            "channels": null
          },
          {
            "index": 1,
            "type": "audio",
            "codec": "pcm_s16le",
            "codec_long_name": "PCM signed 16-bit little-endian",
            "width": null,
            "height": null,
            "frame_rate": null,
            "sample_rate": 8000,
            "channels": 1
          }
        ]
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "ocr-image-to-text",
      "summary": "Reads the text out of an image (scan, screenshot, photo) with Tesseract, returned with a mean confidence.",
      "rationale": "OCR needs a real engine and its language data AND a hardened fetch: an agent that pulls the image URL itself walks past every SSRF control, and carrying a WASM Tesseract plus a language model is not something an agent does per call.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 45000,
      "max_timeout_seconds": 66,
      "max_bytes": 10485760,
      "endpoint": "https://grist.tools/v1/ocr-image-to-text",
      "schema_url": "https://grist.tools/v1/ocr-image-to-text/schema",
      "docs_url": "https://grist.tools/docs/ocr-image-to-text",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the image, up to 2048 characters and 10 MB; redirects are followed, the bytes must be PNG, JPEG, TIFF, WebP or GIF by signature, and at most 40 megapixels."
          },
          "lang": {
            "default": "eng",
            "description": "Tesseract language model to read the text with; only languages whose data ships with the service are accepted, today eng (the default).",
            "type": "string",
            "enum": [
              "eng"
            ]
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/images/ocr-scan.png",
        "lang": "eng"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/images/ocr-scan.png",
        "lang": "eng",
        "image_format": "png",
        "text": "SAMPLE SCAN FOR OCR\nSECOND LINE 2026\n",
        "confidence": 86
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "page-metadata",
      "summary": "Fetches a page and returns its <head> metadata -- title, description, canonical, language, hreflang, favicon, Open Graph and Twitter cards -- with every URL resolved to absolute.",
      "rationale": "It needs the network and three libraries an agent scraping many pages does not carry: an HTML parser, a legacy charset decoder, and a resolver that honours <base href> and the final redirect hop. Feeding raw HTML to a model to pull these fields costs far more in tokens than this call.",
      "family": "fetch",
      "shelf": "web",
      "category": "Data",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 8000,
      "max_timeout_seconds": 29,
      "max_bytes": 3145728,
      "endpoint": "https://grist.tools/v1/page-metadata",
      "schema_url": "https://grist.tools/v1/page-metadata/schema",
      "docs_url": "https://grist.tools/docs/page-metadata",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of an HTML or XHTML page, up to 2048 characters; up to 5 redirects are followed and relative head URLs resolve against the final hop or its <base href>."
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/web/render-article.html"
      },
      "output_example": {
        "url": "https://grist.tools/samples/web/render-article.html",
        "source_url": "https://grist.tools/samples/web/render-article.html",
        "final_url": "https://grist.tools/samples/web/render-article.html",
        "http_status": 200,
        "title": "Measuring Rainfall with a Simple Gauge",
        "description": "A short guide to reading a cylinder rain gauge at the same hour every day.",
        "canonical": "https://grist.tools/samples/web/render-article.html",
        "lang": "en",
        "hreflang": [
          {
            "lang": "en",
            "href": "https://grist.tools/samples/web/render-article.html"
          },
          {
            "lang": "x-default",
            "href": "https://grist.tools/samples/web/render-article.html"
          }
        ],
        "hreflang_total": 2,
        "hreflang_truncated": false,
        "favicon": "https://grist.tools/favicon.png",
        "charset": "utf-8",
        "opengraph": {
          "title": "Measuring Rainfall with a Simple Gauge",
          "description": "Read the gauge at eye level, at the same hour, and write the number down.",
          "image": "https://grist.tools/og.png",
          "type": "article",
          "site_name": "Grist Samples",
          "url": "https://grist.tools/samples/web/render-article.html"
        },
        "twitter": {
          "card": "summary_large_image",
          "title": "Measuring Rainfall with a Simple Gauge",
          "description": "Read the gauge at eye level, at the same hour, and write the number down.",
          "image": "https://grist.tools/og.png"
        },
        "robots_meta": "index, follow",
        "fetched_at": "2026-09-01T12:00:00.000Z"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_status",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "pdf-merge",
      "summary": "Concatenates 2–8 source PDFs, in order, into one PDF returned base64-encoded. Maximum output PDF size: 25 MiB before base64 encoding.",
      "rationale": "Merging PDFs needs a real PDF library and a hardened fetch over several URLs at once: an agent doing it inline walks every one past the SSRF controls, and the joined output must be byte-reproducible.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 45000,
      "max_timeout_seconds": 66,
      "max_bytes": 8388608,
      "endpoint": "https://grist.tools/v1/pdf-merge",
      "schema_url": "https://grist.tools/v1/pdf-merge/schema",
      "docs_url": "https://grist.tools/docs/pdf-merge",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "urls": {
            "minItems": 2,
            "maxItems": 8,
            "type": "array",
            "items": {
              "type": "string",
              "maxLength": 2048,
              "format": "uri",
              "description": "Absolute http(s) URL of one source PDF, up to 2048 characters; redirects are followed and the body must start with the %PDF- signature."
            },
            "description": "The source PDFs, 2 to 8 URLs fetched one at a time and concatenated in this order; each is read up to 8 MiB and an encrypted source is refused."
          }
        },
        "required": [
          "urls"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "urls": [
          "https://grist.tools/samples/documents/merge-intro.pdf",
          "https://grist.tools/samples/documents/merge-appendix.pdf"
        ]
      },
      "output_example": {
        "sources": [
          {
            "url": "https://grist.tools/samples/documents/merge-intro.pdf",
            "pages": 1
          },
          {
            "url": "https://grist.tools/samples/documents/merge-appendix.pdf",
            "pages": 2
          }
        ],
        "pages": 3,
        "bytes": 1474,
        "pdf_base64": "JVBERi0xLjcKJYGBgYEKCjQgMCBvYmoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUK..."
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "pdf-metadata",
      "summary": "Reads a PDF's page count, document-info fields, version and encryption flag without rendering it.",
      "rationale": "Reading a PDF trailer needs a real PDF parser and a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and carrying a PDF library per call is not something an agent does inline.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 25000,
      "max_timeout_seconds": 46,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/pdf-metadata",
      "schema_url": "https://grist.tools/v1/pdf-metadata/schema",
      "docs_url": "https://grist.tools/docs/pdf-metadata",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the PDF, up to 2048 characters; redirects are followed, the body must start with %PDF-, and an encrypted file is still described without being decrypted."
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/documents/metadata-brochure.pdf"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/documents/metadata-brochure.pdf",
        "bytes": 1010,
        "version": "1.7",
        "pages": 1,
        "encrypted": false,
        "title": "Sample Brochure",
        "author": "Grist Samples",
        "subject": "Public test document",
        "keywords": "sample metadata test",
        "creator": "grist.tools",
        "producer": "grist.tools",
        "creation_date": "2000-01-01T00:00:00.000Z",
        "modification_date": "2000-01-01T00:00:00.000Z"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "pdf-split",
      "summary": "Extracts a 1-indexed page selection (e.g. \"1,3-4\") from a PDF into one new PDF, returned base64-encoded. Maximum output PDF size: 25 MiB before base64 encoding.",
      "rationale": "Splitting a PDF needs a real PDF library and a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and the output must be byte-reproducible, which a naive save is not.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 30000,
      "max_timeout_seconds": 51,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/pdf-split",
      "schema_url": "https://grist.tools/v1/pdf-split/schema",
      "docs_url": "https://grist.tools/docs/pdf-split",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the source PDF, up to 2048 characters; redirects are followed, the body must start with %PDF-, and an encrypted file is refused."
          },
          "ranges": {
            "type": "string",
            "minLength": 1,
            "maxLength": 512,
            "description": "Comma-separated 1-indexed pages or inclusive ranges such as \"1,3-4\", up to 512 characters; pages are emitted in the order written, repeats are kept, and a descending range or a page past the end is an error."
          }
        },
        "required": [
          "url",
          "ranges"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/documents/split-five-pages.pdf",
        "ranges": "1,3-4"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/documents/split-five-pages.pdf",
        "source_pages": 5,
        "pages": 3,
        "bytes": 1392,
        "pdf_base64": "JVBERi0xLjcKJYGBgYEKCjQgMCBvYmoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUK..."
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "pdf-to-markdown",
      "summary": "Converts a PDF to Markdown, inferring headings from font size (best-effort, not layout-perfect; no OCR).",
      "rationale": "A PDF has no heading structure, only sized glyphs; recovering Markdown needs a real PDF engine plus a hardened fetch, which an agent cannot do inline without walking past the SSRF controls. Offline, best-effort text-layer extraction; no OCR. No external CMap or standard-font data is configured or fetched. PDFs with self-contained character mappings can yield text, including CJK; documents requiring unavailable predefined CMaps can return empty or partial text without an error. Embedded ToUnicode alone does not guarantee extraction when the encoding still needs an external CMap. Missing standard-font data does not necessarily prevent extraction. A successful response does not certify complete text; truncated reports only the max_pages limit.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 40000,
      "max_timeout_seconds": 61,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/pdf-to-markdown",
      "schema_url": "https://grist.tools/v1/pdf-to-markdown/schema",
      "docs_url": "https://grist.tools/docs/pdf-to-markdown",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the PDF, up to 2048 characters; redirects are followed and the body must start with the %PDF- signature."
          },
          "max_pages": {
            "default": 100,
            "description": "How many leading pages to convert, 1 to 1000; a shorter document is not an error, and a longer one is cut and reported as truncated.",
            "type": "integer",
            "minimum": 1,
            "maximum": 1000
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false,
        "description": "Offline, best-effort text-layer extraction; no OCR. No external CMap or standard-font data is configured or fetched. PDFs with self-contained character mappings can yield text, including CJK; documents requiring unavailable predefined CMaps can return empty or partial text without an error. Embedded ToUnicode alone does not guarantee extraction when the encoding still needs an external CMap. Missing standard-font data does not necessarily prevent extraction. A successful response does not certify complete text; truncated reports only the max_pages limit. PDF text extraction accepts at most 1000000 raw and separately 1000000 output UTF-16 code units, 10000 items per page and 100000 items across selected pages. Exceeding a limit rejects the call with 413 too_large. truncated refers only to max_pages."
      },
      "input_example": {
        "url": "https://grist.tools/samples/documents/markdown-guide.pdf",
        "max_pages": 100
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/documents/markdown-guide.pdf",
        "total_pages": 1,
        "extracted_pages": 1,
        "truncated": false,
        "chars": 249,
        "markdown": "# Field Notes\n\n## A short guide in three parts\n\nThis sample shows how headings are inferred.\n\nLarger text becomes a heading, body text stays plain.\n\n### Second section\n\nEach line of the page becomes its own block.\n\nThe document ends after this line."
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "pdf-to-text",
      "summary": "Extracts the text layer of a PDF, page by page, in reading order (no OCR).",
      "rationale": "Pulling a PDF text layer needs a real PDF engine and a hardened fetch: an agent that fetches the URL itself walks past every SSRF control, and carrying pdfjs per call is not something an agent does inline. Offline, best-effort text-layer extraction; no OCR. No external CMap or standard-font data is configured or fetched. PDFs with self-contained character mappings can yield text, including CJK; documents requiring unavailable predefined CMaps can return empty or partial text without an error. Embedded ToUnicode alone does not guarantee extraction when the encoding still needs an external CMap. Missing standard-font data does not necessarily prevent extraction. A successful response does not certify complete text; truncated reports only the max_pages limit.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 40000,
      "max_timeout_seconds": 61,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/pdf-to-text",
      "schema_url": "https://grist.tools/v1/pdf-to-text/schema",
      "docs_url": "https://grist.tools/docs/pdf-to-text",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the PDF, up to 2048 characters; redirects are followed and the body must start with the %PDF- signature."
          },
          "max_pages": {
            "default": 100,
            "description": "How many leading pages to extract, 1 to 1000; a shorter document is not an error, and a longer one is cut and reported as truncated.",
            "type": "integer",
            "minimum": 1,
            "maximum": 1000
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false,
        "description": "Offline, best-effort text-layer extraction; no OCR. No external CMap or standard-font data is configured or fetched. PDFs with self-contained character mappings can yield text, including CJK; documents requiring unavailable predefined CMaps can return empty or partial text without an error. Embedded ToUnicode alone does not guarantee extraction when the encoding still needs an external CMap. Missing standard-font data does not necessarily prevent extraction. A successful response does not certify complete text; truncated reports only the max_pages limit. PDF text extraction accepts at most 1000000 raw and separately 1000000 output UTF-16 code units, 10000 items per page and 100000 items across selected pages. Exceeding a limit rejects the call with 413 too_large. truncated refers only to max_pages."
      },
      "input_example": {
        "url": "https://grist.tools/samples/documents/report.pdf",
        "max_pages": 100
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/documents/report.pdf",
        "total_pages": 2,
        "extracted_pages": 2,
        "truncated": false,
        "chars": 122,
        "text": "Sample Report\nThis is a public test document.\nIt has two pages of plain text.\n\nPage Two\nThe second page closes the report."
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "pptx-to-text",
      "summary": "Extracts the slide text of a PowerPoint .pptx, slide by slide, in order (body text only, up to 1,000 slides).",
      "rationale": "Reading .pptx slide text needs to unpack an OOXML zip through a hardened fetch: an agent fetching the URL itself walks past every SSRF control, and the parsing is fiddly enough to be worth a paid endpoint.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 40000,
      "max_timeout_seconds": 61,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/pptx-to-text",
      "schema_url": "https://grist.tools/v1/pptx-to-text/schema",
      "docs_url": "https://grist.tools/docs/pptx-to-text",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the .pptx, up to 2048 characters; redirects are followed and the body must be a zip holding ppt/slides/slideN.xml parts, at most 1,000 of them."
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/documents/garden-plan.pptx"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/documents/garden-plan.pptx",
        "slide_count": 3,
        "chars": 244,
        "slides": [
          {
            "number": 1,
            "text": "Spring Planting Plan\nCommunity garden season overview"
          },
          {
            "number": 2,
            "text": "Beds and Crops\nNorth beds: beans and peas\nSouth beds: tomatoes & peppers\nHerb spiral: basil, thyme, mint"
          },
          {
            "number": 3,
            "text": "Next Steps\nOrder compost by March\nRepair the east fence\nSchedule the first work day"
          }
        ],
        "text": "Spring Planting Plan\nCommunity garden season overview\n\nBeds and Crops\nNorth beds: beans and peas\nSouth beds: tomatoes & peppers\nHerb spiral: basil, thyme, mint\n\nNext Steps\nOrder compost by March\nRepair the east fence\nSchedule the first work day"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "remote-file-hash",
      "summary": "Computes the sha256, sha1 or md5 digest of a remote file, buffered in memory under a size cap, for checksum verification.",
      "rationale": "Verifying an artefact against a published checksum means pulling the whole file through a hardened fetch and hashing it. An agent that fetches the URL itself walks past every SSRF control and pays the bandwidth anyway.",
      "family": "archives",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 60000,
      "max_timeout_seconds": 81,
      "max_bytes": 104857600,
      "endpoint": "https://grist.tools/v1/remote-file-hash",
      "schema_url": "https://grist.tools/v1/remote-file-hash/schema",
      "docs_url": "https://grist.tools/docs/remote-file-hash",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the file, up to 2048 characters; redirects are followed and the digest is taken over the whole response body (after any HTTP content-encoding is decoded), which must be at most 100 MB."
          },
          "algorithm": {
            "default": "sha256",
            "description": "Digest to compute over the bytes: sha256 (default), sha1 or md5, returned as lowercase hex.",
            "type": "string",
            "enum": [
              "sha256",
              "sha1",
              "md5"
            ]
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/archives/bundle.zip",
        "algorithm": "sha256"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/archives/bundle.zip",
        "bytes": 639,
        "algorithm": "sha256",
        "hash": "07af68ae7f049cf267c89c4c89df15fd1f59153773b308ea6bb7bb0d0585e5c3"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "robots-check",
      "summary": "Fetches robots.txt and answers whether a user agent may crawl a URL, showing the group, the winning rule and every rule that competed with it.",
      "rationale": "It needs the network, and the part an agent gets wrong on its own is precedence: RFC 9309 resolves conflicts by longest match, not first match. One fetch answers up to 50 paths.",
      "family": "feeds",
      "shelf": "web",
      "category": "Data",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 8000,
      "max_timeout_seconds": 29,
      "max_bytes": 512000,
      "endpoint": "https://grist.tools/v1/robots-check",
      "schema_url": "https://grist.tools/v1/robots-check/schema",
      "docs_url": "https://grist.tools/docs/robots-check",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL to check, up to 2048 characters; robots.txt is fetched from the root of its origin and the verdict is for its path and query."
          },
          "user_agent": {
            "default": "*",
            "description": "Crawler product token or full User-Agent string, printable ASCII up to 200 characters; the text before the first slash or space picks the group, and the default is *.",
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "pattern": "^[\\x20-\\x7e]+$"
          },
          "include_sitemaps": {
            "default": true,
            "description": "Whether to return the Sitemap URLs listed in robots.txt; false returns null for sitemaps and its counters.",
            "type": "boolean"
          },
          "additional_paths": {
            "description": "Up to 50 more root-relative paths or absolute URLs on the same origin as url, each answered from the same robots.txt fetch in additional_results.",
            "maxItems": 50,
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 2048
            }
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/v1/whois",
        "user_agent": "GPTBot",
        "include_sitemaps": true,
        "additional_paths": [
          "/docs/whois",
          "/v1/whois/schema"
        ]
      },
      "output_example": {
        "url": "https://grist.tools/v1/whois",
        "source_url": "https://grist.tools/robots.txt",
        "robots_url": "https://grist.tools/robots.txt",
        "robots_status": "found",
        "robots_http_status": 200,
        "user_agent": "GPTBot",
        "user_agent_token": "GPTBot",
        "allowed": false,
        "decision_reason": "rule",
        "matched_rule": {
          "type": "disallow",
          "path": "/v1/",
          "line": 3,
          "octet_length": 4
        },
        "matched_group": {
          "user_agents": [
            "*"
          ],
          "is_wildcard": true,
          "line": 1
        },
        "competing_rules": [
          {
            "type": "disallow",
            "path": "/v1/",
            "line": 3,
            "octet_length": 4
          },
          {
            "type": "allow",
            "path": "/",
            "line": 2,
            "octet_length": 1
          }
        ],
        "competing_rules_total": 2,
        "competing_rules_truncated": false,
        "crawl_delay": null,
        "sitemaps": [
          "https://grist.tools/sitemap.xml"
        ],
        "sitemaps_total": 1,
        "sitemaps_truncated": false,
        "groups_found": 1,
        "rules_parsed": 3,
        "unparsed_lines": 0,
        "robots_size_bytes": 141,
        "robots_truncated": false,
        "additional_results": [
          {
            "path": "/docs/whois",
            "allowed": true,
            "decision_reason": "rule",
            "matched_rule": {
              "type": "allow",
              "path": "/",
              "line": 2,
              "octet_length": 1
            }
          },
          {
            "path": "/v1/whois/schema",
            "allowed": true,
            "decision_reason": "rule",
            "matched_rule": {
              "type": "allow",
              "path": "/v1/*/schema",
              "line": 4,
              "octet_length": 12
            }
          }
        ],
        "fetched_at": "2026-09-01T12:00:00.000Z",
        "checked_at": "2026-09-01T12:00:00.000Z"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "upstream_timeout",
        "internal"
      ]
    },
    {
      "name": "rss-parse",
      "summary": "Fetches an RSS, Atom or RSS 1.0 feed and returns its channel metadata and a normalised list of items -- id, title, link, summary, content, author, dates and categories -- with every date in ISO 8601 UTC.",
      "rationale": "It needs the network and normalises three feed formats that say the same things in different tags and two different date formats, behind an XXE-safe XML parser. One call replaces the three-parser, RFC-822-date, entity-decoding scraper an agent would otherwise carry.",
      "family": "feeds",
      "shelf": "web",
      "category": "Data",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 8000,
      "max_timeout_seconds": 29,
      "max_bytes": 5242880,
      "endpoint": "https://grist.tools/v1/rss-parse",
      "schema_url": "https://grist.tools/v1/rss-parse/schema",
      "docs_url": "https://grist.tools/docs/rss-parse",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the RSS 2.0, Atom or RSS 1.0 (RDF) feed, up to 2048 characters; up to 5 redirects are followed and a DTD is refused."
          },
          "limit": {
            "default": 50,
            "description": "Maximum number of items returned, 1 to 1000 (default 50), counted after the since filter; a longer feed is cut and reported as truncated.",
            "type": "integer",
            "minimum": 1,
            "maximum": 1000
          },
          "since": {
            "description": "ISO 8601 timestamp with Z or an offset; when set, only items whose published (or else updated) date is at or after it are kept, and undated items are dropped.",
            "type": "string",
            "format": "date-time",
            "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/web/fieldnotes/feed.xml",
        "limit": 10
      },
      "output_example": {
        "url": "https://grist.tools/samples/web/fieldnotes/feed.xml",
        "source_url": "https://grist.tools/samples/web/fieldnotes/feed.xml",
        "final_url": "https://grist.tools/samples/web/fieldnotes/feed.xml",
        "http_status": 200,
        "format": "rss",
        "feed": {
          "title": "Field Notes",
          "link": "https://grist.tools/samples/web/fieldnotes/index.html",
          "description": "Short notes on frost, rain and wind from a small test garden.",
          "language": "en",
          "updated": "2026-08-25T06:15:00Z",
          "generator": "Hand-written sample"
        },
        "items": [
          {
            "id": "fieldnotes-rain-gauge",
            "title": "Reading a rain gauge by hand",
            "link": "https://grist.tools/samples/web/fieldnotes/notes/rain-gauge.html",
            "summary": "How the daily rain reading is taken & written down.",
            "content": "<p>The gauge is read once a day at eight in the morning.</p>",
            "author": "Field Notes Editors",
            "published": "2026-08-25T06:15:00Z",
            "updated": null,
            "categories": [
              "rain",
              "method"
            ]
          },
          {
            "id": "https://grist.tools/samples/web/fieldnotes/notes/first-frost.html",
            "title": "The first frost of the season",
            "link": "https://grist.tools/samples/web/fieldnotes/notes/first-frost.html",
            "summary": "A light ground frost on the lower beds, measured at dawn.",
            "content": null,
            "author": "Field Notes Editors",
            "published": "2026-08-24T06:30:00Z",
            "updated": null,
            "categories": [
              "frost"
            ]
          }
        ],
        "count": 2,
        "total": 2,
        "truncated": false,
        "fetched_at": "2026-09-01T12:00:00.000Z"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_status",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "sitemap-parse",
      "summary": "Parses a sitemap, a sitemap index or a bare origin into a flat, bounded list of URLs with lastmod, changefreq and priority.",
      "rationale": "Needs the network, follows a sitemap index across many documents and normalises four different legal sitemap formats. An agent that does it itself either writes an XML parser with the XXE and fan-out defences, or fetches an unbounded tree of documents a stranger chose.",
      "family": "feeds",
      "shelf": "web",
      "category": "Data",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 14000,
      "max_timeout_seconds": 35,
      "max_bytes": 10485760,
      "endpoint": "https://grist.tools/v1/sitemap-parse",
      "schema_url": "https://grist.tools/v1/sitemap-parse/schema",
      "docs_url": "https://grist.tools/docs/sitemap-parse",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "pattern": "^[hH][tT][tT][pP][sS]?:",
            "description": "Absolute http(s) URL, up to 2048 characters, of a sitemap, a sitemap index or a bare origin (path / and no query); a bare origin is resolved through robots.txt Sitemap: lines or /sitemap.xml."
          },
          "limit": {
            "default": 5000,
            "description": "Maximum number of URLs returned across every document read, 1 to 50000 (default 5000); past it the list is cut and reported as truncated.",
            "type": "integer",
            "minimum": 1,
            "maximum": 50000
          },
          "include_metadata": {
            "default": false,
            "description": "When true each URL carries its lastmod, changefreq and priority; when false (default) those three fields are null.",
            "type": "boolean"
          },
          "max_sitemaps": {
            "default": 10,
            "description": "Maximum number of sitemap documents fetched, entry document included, 1 to 50 (default 10); children past it are reported as skipped with reason sitemap_limit.",
            "type": "integer",
            "minimum": 1,
            "maximum": 50
          },
          "discover_from_robots": {
            "default": true,
            "description": "Only for a bare origin: when true (default) the Sitemap: lines of /robots.txt are read first with /sitemap.xml as the fallback, when false /sitemap.xml is used directly.",
            "type": "boolean"
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/web/fieldnotes/sitemap-index.xml",
        "limit": 100,
        "include_metadata": true
      },
      "output_example": {
        "requested_url": "https://grist.tools/samples/web/fieldnotes/sitemap-index.xml",
        "entry_url": "https://grist.tools/samples/web/fieldnotes/sitemap-index.xml",
        "source_url": "https://grist.tools/samples/web/fieldnotes/sitemap-index.xml",
        "discovery": "direct",
        "kind": "mixed",
        "format": "xml",
        "urls": [
          {
            "loc": "https://grist.tools/samples/web/fieldnotes/index.html",
            "lastmod": "2026-08-25T00:00:00Z",
            "changefreq": "daily",
            "priority": 1
          },
          {
            "loc": "https://grist.tools/samples/web/fieldnotes/about.html",
            "lastmod": "2026-08-20T00:00:00Z",
            "changefreq": "yearly",
            "priority": 0.3
          },
          {
            "loc": "https://grist.tools/samples/web/fieldnotes/notes/first-frost.html",
            "lastmod": "2026-08-24T06:30:00Z",
            "changefreq": "monthly",
            "priority": 0.6
          },
          {
            "loc": "https://grist.tools/samples/web/fieldnotes/notes/rain-gauge.html",
            "lastmod": "2026-08-25T06:15:00Z",
            "changefreq": null,
            "priority": 0.6
          }
        ],
        "count": 4,
        "total_seen": 4,
        "truncated": false,
        "stopped_reason": null,
        "sitemap_count": 3,
        "sitemaps_visited": [
          {
            "url": "https://grist.tools/samples/web/fieldnotes/sitemap-index.xml",
            "kind": "sitemapindex",
            "url_count": 0,
            "http_status": 200,
            "bytes": 407,
            "gzip": false,
            "depth": 0
          },
          {
            "url": "https://grist.tools/samples/web/fieldnotes/sitemap-pages.xml",
            "kind": "urlset",
            "url_count": 2,
            "http_status": 200,
            "bytes": 479,
            "gzip": false,
            "depth": 1
          },
          {
            "url": "https://grist.tools/samples/web/fieldnotes/sitemap-notes.xml",
            "kind": "urlset",
            "url_count": 2,
            "http_status": 200,
            "bytes": 493,
            "gzip": false,
            "depth": 1
          }
        ],
        "sitemaps_skipped": [],
        "sitemaps_skipped_total": 0,
        "sitemaps_skipped_by_reason": {
          "depth_limit": 0,
          "sitemap_limit": 0,
          "cross_host": 0,
          "too_large": 0,
          "malformed": 0,
          "duplicate": 0,
          "time_budget": 0,
          "not_fetchable": 0
        },
        "invalid_loc_count": 0,
        "invalid_lastmod_count": 0,
        "robots_sitemaps": null,
        "fetched_at": "2026-09-01T12:00:00.000Z"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_status",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "ssl-cert-info",
      "summary": "Fetches the TLS certificate a host presents: issuer, subject, SAN list, validity window, days to expiry and the full chain.",
      "rationale": "Reading a live certificate needs a TLS handshake against the host, which an agent may be unable to make; the days-to-expiry and chain view are what you monitor across a fleet of domains.",
      "family": "domain",
      "shelf": "web",
      "category": "Verification",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 10000,
      "max_timeout_seconds": 31,
      "max_bytes": 2097152,
      "endpoint": "https://grist.tools/v1/ssl-cert-info",
      "schema_url": "https://grist.tools/v1/ssl-cert-info/schema",
      "docs_url": "https://grist.tools/docs/ssl-cert-info",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "domain": {
            "description": "Public fully qualified domain name to connect to and send as SNI. Give exactly one of domain or url.",
            "type": "string",
            "minLength": 1
          },
          "url": {
            "description": "An http(s) URL whose host is probed; an explicit port in the URL overrides the port field, and the path is ignored. Give exactly one of domain or url.",
            "type": "string",
            "minLength": 1
          },
          "port": {
            "default": 443,
            "description": "TLS port for the handshake, one of 443, 465, 636, 990, 993, 995, 8443; defaults to 443.",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "additionalProperties": false
      },
      "input_example": {
        "domain": "example.com"
      },
      "output_example": {
        "host": "example.com",
        "port": 443,
        "issuer": "CN=R3, O=Let's Encrypt, C=US",
        "subject": "CN=example.com, O=Example Inc, C=US",
        "san": [
          "example.com",
          "www.example.com"
        ],
        "valid_from": "2026-08-01T00:00:00.000Z",
        "valid_to": "2026-10-30T23:59:59.000Z",
        "days_to_expiry": 65,
        "self_signed": false,
        "chain": [
          {
            "issuer": "CN=R3, O=Let's Encrypt, C=US",
            "subject": "CN=example.com, O=Example Inc, C=US",
            "valid_from": "2026-08-01T00:00:00.000Z",
            "valid_to": "2026-10-30T23:59:59.000Z"
          },
          {
            "issuer": "CN=ISRG Root X1, O=Internet Security Research Group, C=US",
            "subject": "CN=R3, O=Let's Encrypt, C=US",
            "valid_from": "2020-09-04T00:00:00.000Z",
            "valid_to": "2025-09-15T16:00:00.000Z"
          }
        ],
        "queried_at": "2026-08-26T18:00:00.000Z"
      },
      "example_kind": "illustrative",
      "example_note": "This example is illustrative: it shows the exact shape the handler returns, but it was not produced by a reproducible call, because the real answer depends on live network data that changes over time.",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "internal"
      ]
    },
    {
      "name": "structured-data-extract",
      "summary": "Fetches a web page and returns its embedded machine-readable data -- JSON-LD, OpenGraph, microdata and meta tags -- plus a normalised merge across all four.",
      "rationale": "It needs the network and a real DOM, and the extraction is the code an agent keeps re-implementing wrong: skipping a broken JSON-LD script instead of throwing, telling og: from name= apart, and walking microdata with its parent/child nesting rule. One call replaces four brittle scrapers.",
      "family": "fetch",
      "shelf": "web",
      "category": "Data",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 8000,
      "max_timeout_seconds": 29,
      "max_bytes": 5242880,
      "endpoint": "https://grist.tools/v1/structured-data-extract",
      "schema_url": "https://grist.tools/v1/structured-data-extract/schema",
      "docs_url": "https://grist.tools/docs/structured-data-extract",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the HTML or XHTML page to read, up to 2048 characters; up to 5 redirects are followed and relative URLs resolve against the final URL or its <base href>."
          },
          "types": {
            "description": "Which of jsonld, opengraph, microdata and meta to extract, at least one; omitted means all four, and a type not requested comes back null and is left out of the normalized merge.",
            "minItems": 1,
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "jsonld",
                "opengraph",
                "microdata",
                "meta"
              ]
            }
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/web/fieldnotes/notes/first-frost.html"
      },
      "output_example": {
        "url": "https://grist.tools/samples/web/fieldnotes/notes/first-frost.html",
        "source_url": "https://grist.tools/samples/web/fieldnotes/notes/first-frost.html",
        "final_url": "https://grist.tools/samples/web/fieldnotes/notes/first-frost.html",
        "http_status": 200,
        "jsonld": [
          {
            "@context": "https://schema.org",
            "@type": "Article",
            "headline": "The first frost of the season",
            "description": "A light ground frost on the lower beds, measured at dawn.",
            "image": "../icon-32.png",
            "datePublished": "2026-08-24T06:30:00Z",
            "author": {
              "@type": "Organization",
              "name": "Field Notes Editors"
            }
          }
        ],
        "jsonld_total": 1,
        "jsonld_truncated": false,
        "jsonld_parse_errors": 0,
        "opengraph": {
          "og:title": "The first frost of the season",
          "og:type": "article",
          "og:url": "https://grist.tools/samples/web/fieldnotes/notes/first-frost.html",
          "og:image": "https://grist.tools/samples/web/fieldnotes/icon-32.png"
        },
        "microdata": [
          {
            "type": "https://schema.org/Place",
            "properties": {
              "name": "Lower beds",
              "description": "The lowest corner of the garden, where cold air settles first."
            }
          }
        ],
        "microdata_total": 1,
        "microdata_truncated": false,
        "meta": {
          "description": "A light ground frost on the lower beds, measured at dawn.",
          "author": "Field Notes Editors",
          "article:published_time": "2026-08-24T06:30:00Z"
        },
        "normalized": {
          "title": "The first frost of the season",
          "description": "A light ground frost on the lower beds, measured at dawn.",
          "image": "https://grist.tools/samples/web/fieldnotes/icon-32.png",
          "type": "Article",
          "author": "Field Notes Editors",
          "published": "2026-08-24T06:30:00Z"
        },
        "fetched_at": "2026-09-01T12:00:00.000Z"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_status",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "subtitle-convert",
      "summary": "Converts a subtitle track between SubRip (.srt) and WebVTT (.vtt), normalising the cues.",
      "rationale": "It fetches through a hardened guard and re-serialises the cue model rather than string-patching, so positioning settings, cue ids and NOTE blocks convert cleanly instead of corrupting the output an agent would have to repair.",
      "family": "av",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 15000,
      "max_timeout_seconds": 36,
      "max_bytes": 5242880,
      "endpoint": "https://grist.tools/v1/subtitle-convert",
      "schema_url": "https://grist.tools/v1/subtitle-convert/schema",
      "docs_url": "https://grist.tools/docs/subtitle-convert",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of a SubRip or WebVTT file, at most 2048 characters; the source format is detected from the content, not the extension."
          },
          "to": {
            "default": "vtt",
            "description": "Target format, vtt (default) or srt; cues are re-serialised, so SRT output is renumbered and VTT cue ids, settings and NOTE blocks are dropped.",
            "type": "string",
            "enum": [
              "srt",
              "vtt"
            ]
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/av/subtitle-captions.srt",
        "to": "vtt"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/av/subtitle-captions.srt",
        "source_format": "srt",
        "target_format": "vtt",
        "cue_count": 3,
        "subtitle": "WEBVTT\n\n00:00:01.000 --> 00:00:03.500\nThe tide goes out slowly.\n\n00:00:04.000 --> 00:00:06.250\nShells line the wet sand,\none row after another.\n\n00:00:07.000 --> 00:00:09.000\nBy noon the water returns.\n"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "subtitle-extract",
      "summary": "Extracts a text subtitle track from a media container (MKV, MP4, WebM) as SubRip or WebVTT.",
      "rationale": "Demuxing a caption stream out of a container needs ffmpeg AND a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and carrying an ffmpeg per call is not something an agent does inline.",
      "family": "av",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 60000,
      "max_timeout_seconds": 81,
      "max_bytes": 209715200,
      "endpoint": "https://grist.tools/v1/subtitle-extract",
      "schema_url": "https://grist.tools/v1/subtitle-extract/schema",
      "docs_url": "https://grist.tools/docs/subtitle-extract",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of a media container that can carry subtitles (MKV/WebM, MP4/MOV, AVI or Ogg), at most 2048 characters."
          },
          "stream": {
            "default": 0,
            "description": "Zero-based index counted among the subtitle streams only (0 to 63, default 0); a missing stream fails with no_subtitle_stream.",
            "type": "integer",
            "minimum": 0,
            "maximum": 63
          },
          "to": {
            "default": "srt",
            "description": "Format of the returned captions, srt (default) or vtt, written by ffmpeg.",
            "type": "string",
            "enum": [
              "srt",
              "vtt"
            ]
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/av/subtitle-movie.mkv",
        "stream": 0,
        "to": "srt"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/av/subtitle-movie.mkv",
        "bytes": 1486,
        "container": "matroska",
        "stream_index": 0,
        "format": "srt",
        "cue_count": 2,
        "subtitle": "1\n00:00:00,500 --> 00:00:02,000\nThe kettle starts to hum.\n\n2\n00:00:02,500 --> 00:00:04,000\nSteam rises from the spout.\n\n"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "svg-to-png",
      "summary": "Rasterises an SVG to a PNG at a chosen resolution, returned base64-encoded.",
      "rationale": "Rasterising an SVG safely needs a real renderer and a hardened fetch: an SVG can point at internal addresses, so an agent that renders one itself walks past every SSRF control, and the output must be byte-reproducible.",
      "family": "images",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 60000,
      "max_timeout_seconds": 81,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/svg-to-png",
      "schema_url": "https://grist.tools/v1/svg-to-png/schema",
      "docs_url": "https://grist.tools/docs/svg-to-png",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the SVG, up to 2048 characters; redirects are followed and the body must look like SVG markup; external references in it are never fetched."
          },
          "width": {
            "description": "Target width in pixels, 1 to 20000; when width or height is set the rendered raster is resized to fit inside that box, keeping its aspect ratio, and may be scaled up.",
            "type": "integer",
            "minimum": 1,
            "maximum": 20000
          },
          "height": {
            "description": "Target height in pixels, 1 to 20000; when width or height is set the rendered raster is resized to fit inside that box, keeping its aspect ratio, and may be scaled up.",
            "type": "integer",
            "minimum": 1,
            "maximum": 20000
          },
          "density": {
            "default": 96,
            "description": "Render resolution in DPI, 1 to 2400, default 96; the natural raster size scales with density / 72, so a 64-unit-wide SVG renders 85 pixels wide at 96.",
            "type": "integer",
            "minimum": 1,
            "maximum": 2400
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/images/svg-badge.svg",
        "density": 192
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/images/svg-badge.svg",
        "format": "png",
        "width": 171,
        "height": 128,
        "bytes": 2021,
        "image_base64": "iVBORw0KGgoAAAANSUhEUgAAAKsAAACACAYAAAB0g5nsAAAACXBIWXMAAB2HAAAd..."
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "url-to-markdown",
      "summary": "Fetches a web page and returns the article as clean markdown, with its title, byline and final URL.",
      "rationale": "Raw HTML is mostly navigation, scripts and markup an agent pays for in tokens and then has to strip itself; this returns only the article, as markdown with headings, lists and resolved links, fetched through an SSRF-safe client that re-checks every redirect.",
      "family": "fetch",
      "shelf": "web",
      "category": "Data",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 12000,
      "max_timeout_seconds": 33,
      "max_bytes": 1048576,
      "endpoint": "https://grist.tools/v1/url-to-markdown",
      "schema_url": "https://grist.tools/v1/url-to-markdown/schema",
      "docs_url": "https://grist.tools/docs/url-to-markdown",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Page to fetch, http or https. Redirects are followed and every hop is checked against private and reserved addresses."
          },
          "max_chars": {
            "default": 50000,
            "description": "Cut applied to the returned markdown, counted in Unicode code points.",
            "type": "integer",
            "minimum": 200,
            "maximum": 200000
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://kitchen-ledger.blog/posts/weekday-starter",
        "max_chars": 2000
      },
      "output_example": {
        "title": "Feeding a Sourdough Starter on a Weekday Schedule",
        "byline": "Marta Rinaldi",
        "excerpt": "A feeding routine that keeps a rye starter active on a nine-to-five schedule, with ratios and timings.",
        "site_name": "Kitchen Ledger",
        "lang": "en",
        "markdown": "By [Marta Rinaldi](https://kitchen-ledger.blog/authors/marta-rinaldi/)\n\nMost starter guides assume you are home all day, ready to feed every twelve hours on the dot. A starter kept on a weekday routine can stay just as lively, provided the flour, the ratio and the temperature do the work that a flexible timetable cannot.\n\n## The ratio that buys you time\n\nA stiffer, less frequently fed starter ferments more slowly. Feeding one part starter to five parts flour and five parts water stretches the peak from four hours to roughly ten at a kitchen temperature of twenty-two degrees, which is long enough to cover a working day.\n\n-   Morning, before work: discard all but 10 g and feed 50 g flour and 50 g water.\n-   Evening: use the starter at its peak, or refrigerate it overnight.\n-   Weekend: return to two feeds a day at one to one to one if you plan to bake.\n\n## Choosing the flour\n\nWhole rye ferments faster and more predictably than white wheat, so a small share of it keeps a slow schedule from stalling. The [guide to flour protein](https://kitchen-ledger.blog/guides/flour-protein/) explains why, and the [baker's percentage tables](https://www.bakerspercentage.net/tables) help when you scale a recipe up.\n\nIf the starter smells sharply of acetone by evening, it is hungry: shorten the interval or lower the temperature rather than adding more flour at once.",
        "length": 1368,
        "truncated": false,
        "readability_applied": true,
        "url_final": "https://kitchen-ledger.blog/posts/weekday-starter",
        "fetched_at": "2026-09-26T09:30:00.000Z"
      },
      "example_kind": "illustrative",
      "example_note": "This example is illustrative: it shows the exact shape the handler returns, but it was not produced by a reproducible call, because the real answer depends on live network data that changes over time.",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_status",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "url-unshorten",
      "summary": "Follows a shortened or tracking URL to its real destination and returns every hop it went through.",
      "rationale": "Resolving a short link needs the network, and doing it safely needs a hardened client: an agent that follows the redirects itself will happily walk into a private address or a redirect loop.",
      "family": "fetch",
      "shelf": "web",
      "category": "Data",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 10000,
      "max_timeout_seconds": 31,
      "max_bytes": 65536,
      "endpoint": "https://grist.tools/v1/url-unshorten",
      "schema_url": "https://grist.tools/v1/url-unshorten/schema",
      "docs_url": "https://grist.tools/docs/url-unshorten",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "The short or tracking URL to follow, up to 2048 characters; every hop, the first included, goes through the SSRF guard."
          },
          "max_hops": {
            "default": 10,
            "description": "Most redirects to follow, 1 to 10, default 10; a chain that has not ended by then fails as unprocessable, and a meta refresh at the limit stops with stopped_reason hop_limit.",
            "type": "integer",
            "minimum": 1,
            "maximum": 10
          },
          "method": {
            "default": "auto",
            "description": "auto sends HEAD and retries the same hop with GET on a 400, 403, 405 or 501; head and get force one method for every hop.",
            "type": "string",
            "enum": [
              "auto",
              "head",
              "get"
            ]
          },
          "follow_meta_refresh": {
            "default": false,
            "description": "Whether to also follow an HTML meta refresh with a delay of 30 seconds or less, found in the first 64 KiB of a 2xx HTML or untyped page; method head turns it off, and JavaScript redirects are never followed.",
            "type": "boolean"
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/web/unshorten-start.html",
        "max_hops": 10,
        "follow_meta_refresh": true
      },
      "output_example": {
        "requested_url": "https://grist.tools/samples/web/unshorten-start.html",
        "source_url": "https://grist.tools/samples/web/unshorten-start.html",
        "final_url": "https://grist.tools/samples/web/unshorten-landing.html",
        "final_status": 200,
        "final_host": "grist.tools",
        "final_content_type": "text/html; charset=utf-8",
        "hop_count": 1,
        "reached_final": true,
        "stopped_reason": null,
        "cross_host": false,
        "hops": [
          {
            "url": "https://grist.tools/samples/web/unshorten-start.html",
            "method": "GET",
            "status": 200,
            "location": "unshorten-landing.html",
            "resolved_location": "https://grist.tools/samples/web/unshorten-landing.html",
            "kind": "meta_refresh",
            "host": "grist.tools"
          },
          {
            "url": "https://grist.tools/samples/web/unshorten-landing.html",
            "method": "GET",
            "status": 200,
            "location": null,
            "resolved_location": null,
            "kind": "final",
            "host": "grist.tools"
          }
        ],
        "initial_host_is_known_shortener": false,
        "shortener_list_version": "2026-08-26",
        "checked_at": "2026-09-01T12:00:00.000Z",
        "fetched_at": "2026-09-01T12:00:00.000Z"
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "video-thumbnail",
      "summary": "Grabs a single frame from a video at a chosen timestamp as a PNG or JPEG, optionally scaled to a width.",
      "rationale": "Seeking and decoding one frame needs ffmpeg AND a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and carrying an ffmpeg per call is not something an agent does inline.",
      "family": "av",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 60000,
      "max_timeout_seconds": 81,
      "max_bytes": 209715200,
      "endpoint": "https://grist.tools/v1/video-thumbnail",
      "schema_url": "https://grist.tools/v1/video-thumbnail/schema",
      "docs_url": "https://grist.tools/docs/video-thumbnail",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the video, at most 2048 characters; the container (MP4/MOV, MKV/WebM, AVI or Ogg) is detected from the bytes."
          },
          "time_seconds": {
            "default": 0,
            "description": "Timestamp of the frame to grab, in seconds from the start (0 to 86400, default 0); a time past the end fails with no_frame.",
            "type": "number",
            "minimum": 0,
            "maximum": 86400
          },
          "format": {
            "default": "png",
            "description": "Encoding of the returned frame: png (default) or jpeg.",
            "type": "string",
            "enum": [
              "png",
              "jpeg"
            ]
          },
          "width": {
            "description": "Output width in pixels (16 to 4096); the height keeps the aspect ratio, rounded to an even number. Native size when omitted.",
            "type": "integer",
            "minimum": 16,
            "maximum": 4096
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/av/thumbnail-clip.mp4",
        "time_seconds": 1,
        "format": "png"
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/av/thumbnail-clip.mp4",
        "bytes": 2587,
        "container": "mp4",
        "time_seconds": 1,
        "format": "png",
        "width": 128,
        "height": 72,
        "image_bytes": 292,
        "image_base64": "iVBORw0KGgoAAAANSUhEUgAAAIAAAABICAIAAACx52pFAAAACXBIWXMAAAABAAAA..."
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "waveform-data",
      "summary": "Reduces an audio file to a fixed number of normalised waveform peaks for drawing, without shipping samples.",
      "rationale": "Decoding audio to peaks needs ffmpeg AND a hardened fetch: an agent that pulls the URL itself walks past every SSRF control, and carrying an ffmpeg per call is not something an agent does inline.",
      "family": "av",
      "shelf": "media",
      "category": "Compute",
      "price_usd": "0.003",
      "free": false,
      "timeout_ms": 90000,
      "max_timeout_seconds": 111,
      "max_bytes": 104857600,
      "endpoint": "https://grist.tools/v1/waveform-data",
      "schema_url": "https://grist.tools/v1/waveform-data/schema",
      "docs_url": "https://grist.tools/docs/waveform-data",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of an audio or video file, up to 2048 characters, in a recognised container (MP4/MOV, Matroska/WebM, AVI, Ogg, WAV, FLAC, AIFF or MP3); its first audio stream is decoded to mono."
          },
          "peaks": {
            "default": 200,
            "description": "Number of buckets to return, 1 to 4000, default 200; each is the largest absolute sample in an equal slice of the track, scaled to 0..1 against 16-bit full scale.",
            "type": "integer",
            "minimum": 1,
            "maximum": 4000
          },
          "sample_rate": {
            "default": 8000,
            "description": "Decode rate in Hz, one of 4000, 8000, 11025, 16000, 22050 or 44100, default 8000; it sets sample_count and how finely each bucket is scanned.",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/av/waveform-beeps.wav",
        "peaks": 20,
        "sample_rate": 8000
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/av/waveform-beeps.wav",
        "bytes": 32044,
        "container": "wav",
        "sample_rate": 8000,
        "sample_count": 16000,
        "duration_seconds": 2,
        "peak_count": 20,
        "peaks": [
          0.25,
          0.25,
          0.25,
          0.25,
          0,
          0,
          0,
          0.5,
          0.5,
          0.5,
          0.5,
          0,
          0,
          0,
          0.8999,
          0.8999,
          0.8999,
          0.8999,
          0,
          0
        ]
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "wellknown-fetch",
      "summary": "Fetches and parses a domain's well-known files -- security.txt (RFC 9116), ads.txt and humans.txt -- returning typed fields, seller records and a cross-file summary in one call.",
      "rationale": "It needs the network and a hardened client: the files are named by a stranger, so a naive fetch of /ads.txt is an SSRF probe, and the value is the parse -- PGP-signed security.txt fields, ads.txt split into DIRECT/RESELLER records -- an agent would otherwise build and maintain itself.",
      "family": "feeds",
      "shelf": "web",
      "category": "Data",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 12000,
      "max_timeout_seconds": 33,
      "max_bytes": 2097152,
      "endpoint": "https://grist.tools/v1/wellknown-fetch",
      "schema_url": "https://grist.tools/v1/wellknown-fetch/schema",
      "docs_url": "https://grist.tools/docs/wellknown-fetch",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "domain": {
            "description": "Fully qualified domain name whose files are fetched over https, up to 253 characters; it is trimmed, lowercased and stripped of trailing dots, and IP literals, single-label names and private suffixes are refused.",
            "type": "string",
            "minLength": 1,
            "maxLength": 253
          },
          "files": {
            "description": "Which files to fetch, from security.txt (read at /.well-known/security.txt), ads.txt and humans.txt (read at the root); duplicates collapse, and the default is all three.",
            "minItems": 1,
            "maxItems": 3,
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "security.txt",
                "ads.txt",
                "humans.txt"
              ]
            }
          }
        },
        "required": [
          "domain"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "domain": "example.com"
      },
      "output_example": {
        "domain": "example.com",
        "files_requested": [
          "security.txt",
          "ads.txt",
          "humans.txt"
        ],
        "security_txt": {
          "requested": true,
          "present": true,
          "status": 200,
          "url": "https://example.com/.well-known/security.txt",
          "contact": [
            "mailto:security@example.com",
            "https://example.com/security-contact"
          ],
          "expires": "2027-01-01T00:00:00.000Z",
          "is_expired": false,
          "encryption": [
            "https://example.com/pgp-key.txt"
          ],
          "acknowledgments": [
            "https://example.com/hall-of-fame"
          ],
          "preferred_languages": "en, fr",
          "canonical": [
            "https://example.com/.well-known/security.txt"
          ],
          "policy": [
            "https://example.com/security-policy"
          ],
          "hiring": [
            "https://example.com/jobs"
          ],
          "signed": false,
          "field_count": 9
        },
        "ads_txt": {
          "requested": true,
          "present": true,
          "status": 200,
          "url": "https://example.com/ads.txt",
          "records": [
            {
              "advertising_system": "greenadexchange.com",
              "publisher_id": "12345",
              "relationship": "DIRECT",
              "certification_authority_id": "d75815a79",
              "line": 4
            },
            {
              "advertising_system": "blueadexchange.com",
              "publisher_id": "xf7",
              "relationship": "RESELLER",
              "certification_authority_id": null,
              "line": 5
            },
            {
              "advertising_system": "silverssp.com",
              "publisher_id": "9675",
              "relationship": "DIRECT",
              "certification_authority_id": null,
              "line": 6
            }
          ],
          "records_total": 3,
          "records_truncated": false,
          "direct_count": 2,
          "reseller_count": 1,
          "variables": [
            {
              "name": "CONTACT",
              "value": "adops@example.com"
            }
          ],
          "variables_truncated": false,
          "invalid_lines": 0
        },
        "humans_txt": {
          "requested": true,
          "present": true,
          "status": 200,
          "url": "https://example.com/humans.txt",
          "text": "/* TEAM */\nWebmaster: Jane Doe\nSite: example.com\n\n/* THANKS */\nEveryone.\n",
          "truncated": false,
          "bytes": 73
        },
        "normalised": {
          "files_present": [
            "ads.txt",
            "humans.txt",
            "security.txt"
          ],
          "security_contacts": [
            "mailto:security@example.com",
            "https://example.com/security-contact"
          ],
          "security_expires": "2027-01-01T00:00:00.000Z",
          "security_expired": false,
          "security_signed": false,
          "ads_sellers": 3,
          "ads_direct": 2,
          "ads_reseller": 1
        },
        "fetched_at": "2026-08-26T18:00:00.000Z"
      },
      "example_kind": "illustrative",
      "example_note": "This example is illustrative: it shows the exact shape the handler returns, but it was not produced by a reproducible call, because the real answer depends on live network data that changes over time.",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "too_large",
        "internal"
      ]
    },
    {
      "name": "whois",
      "summary": "Registration record for a domain over whois: registrar, creation/update/expiry dates, status codes and nameservers, with the raw response.",
      "rationale": "whois is a port-43 protocol an agent is unlikely to speak, behind a per-TLD server map and an IANA referral step; the parsed fields plus a confidence score are what make the raw record usable.",
      "family": "domain",
      "shelf": "web",
      "category": "Verification",
      "price_usd": "0.002",
      "free": false,
      "timeout_ms": 12000,
      "max_timeout_seconds": 33,
      "max_bytes": 2097152,
      "endpoint": "https://grist.tools/v1/whois",
      "schema_url": "https://grist.tools/v1/whois/schema",
      "docs_url": "https://grist.tools/docs/whois",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "domain": {
            "description": "Public fully qualified domain name to look up, up to 253 characters; it is lowercased and stripped of trailing dots, and its TLD picks the registry whois server (built-in map, else the IANA referral).",
            "type": "string",
            "minLength": 1,
            "maxLength": 253
          }
        },
        "required": [
          "domain"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "domain": "example.com"
      },
      "output_example": {
        "domain": "example.com",
        "registrar": "Example Registrar, LLC",
        "created": "1995-08-14T04:00:00.000Z",
        "updated": "2025-08-14T07:01:44.000Z",
        "expires": "2026-08-13T04:00:00.000Z",
        "status": [
          "clientTransferProhibited"
        ],
        "nameservers": [
          "a.iana-servers.net",
          "b.iana-servers.net"
        ],
        "raw": "Domain Name: EXAMPLE.COM\nRegistrar: Example Registrar, LLC\nRegistrar WHOIS Server: whois.exampleregistrar.com\nUpdated Date: 2025-08-14T07:01:44Z\nCreation Date: 1995-08-14T04:00:00Z\nRegistry Expiry Date: 2026-08-13T04:00:00Z\nDomain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited\nName Server: A.IANA-SERVERS.NET\nName Server: B.IANA-SERVERS.NET",
        "parsed_confidence": 1
      },
      "example_kind": "illustrative",
      "example_note": "This example is illustrative: it shows the exact shape the handler returns, but it was not produced by a reproducible call, because the real answer depends on live network data that changes over time.",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unprocessable",
        "internal"
      ]
    },
    {
      "name": "xlsx-to-json",
      "summary": "Reads an Excel .xlsx into JSON rows, keyed by a header row or as arrays; dates become ISO strings. Archives are limited to 64 worksheets.",
      "rationale": "Parsing a real .xlsx needs a spreadsheet engine plus a hardened fetch: an agent fetching the URL itself walks past every SSRF control, and carrying exceljs per call is not something an agent does inline.",
      "family": "documents",
      "shelf": "documents",
      "category": "Content",
      "price_usd": "0.005",
      "free": false,
      "timeout_ms": 40000,
      "max_timeout_seconds": 61,
      "max_bytes": 26214400,
      "endpoint": "https://grist.tools/v1/xlsx-to-json",
      "schema_url": "https://grist.tools/v1/xlsx-to-json/schema",
      "docs_url": "https://grist.tools/docs/xlsx-to-json",
      "input_schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 2048,
            "format": "uri",
            "description": "Absolute http(s) URL of the .xlsx, up to 2048 characters; redirects are followed, the body must be a zip, and exceljs must load at least one worksheet from it (at most 64)."
          },
          "sheet": {
            "description": "Exact name of the one worksheet to return, 1 to 255 characters; omitted, every worksheet is returned, and a name not in the workbook is a 422 listing the available names.",
            "type": "string",
            "minLength": 1,
            "maxLength": 255
          },
          "header_row": {
            "default": 1,
            "description": "The 1-based row holding the column names, 0 to 1000, default 1; data starts on the next row, and 0 returns every row as a positional array with columns set to null.",
            "type": "integer",
            "minimum": 0,
            "maximum": 1000
          },
          "max_rows": {
            "default": 1000,
            "description": "Maximum data rows returned per worksheet, 1 to 50000, default 1000; a sheet with more rows is cut and marked truncated.",
            "type": "integer",
            "minimum": 1,
            "maximum": 50000
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": false
      },
      "input_example": {
        "url": "https://grist.tools/samples/documents/garden-inventory.xlsx",
        "header_row": 1,
        "max_rows": 1000
      },
      "output_example": {
        "source_url": "https://grist.tools/samples/documents/garden-inventory.xlsx",
        "sheet_count": 2,
        "sheets": [
          {
            "name": "Seeds",
            "columns": [
              "Variety",
              "Crop",
              "Packets",
              "Sown On",
              "Organic"
            ],
            "rows": [
              {
                "Variety": "Early Scarlet",
                "Crop": "Radish",
                "Packets": 4,
                "Sown On": "2026-03-14T00:00:00.000Z",
                "Organic": true
              },
              {
                "Variety": "Green Arrow",
                "Crop": "Pea",
                "Packets": 6,
                "Sown On": "2026-03-21T00:00:00.000Z",
                "Organic": true
              },
              {
                "Variety": "Golden Cherry",
                "Crop": "Tomato",
                "Packets": 2,
                "Sown On": "2026-04-11T00:00:00.000Z",
                "Organic": false
              }
            ],
            "row_count": 3,
            "truncated": false
          },
          {
            "name": "Tools",
            "columns": [
              "Tool",
              "Count",
              "Location"
            ],
            "rows": [
              {
                "Tool": "Spade",
                "Count": 3,
                "Location": "Shed"
              },
              {
                "Tool": "Watering can",
                "Count": 5,
                "Location": "Rain barrels"
              }
            ],
            "row_count": 2,
            "truncated": false
          }
        ]
      },
      "example_kind": "real",
      "errors": [
        "invalid_input",
        "blocked_target",
        "unreachable_target",
        "upstream_timeout",
        "unsupported_content_type",
        "too_large",
        "unprocessable",
        "internal"
      ]
    }
  ]
}