Skip to content

Tool calls (type: call)

A call field gets its value from an HTTP API, called by code in the middle of the run. Its result can be shown to later fields, checked by on rules and used in *_expr expressions. The whole definition lives in the schema, so any client (for example several departments calling one server) sends self-contained schemas; nothing has to be registered on the server.

"price": {
  "type": "call",
  "http": {
    "method": "GET",
    "url": "https://api.coingecko.com/api/v3/simple/price",
    "query": {"ids": "{coin}", "vs_currencies": "usd,idr"},
    "headers": {"Authorization": "Bearer {{secret.token}}"},
    "timeout": 10,
    "response": {"select": {"usd": "{coin}.usd", "idr": "{coin}.idr"}, "not_found": [404]}
  },
  "args": {"coin": "$coin"},
  "expose": ["usd", "idr"],
  "on_error": "review",
  "on": [{"at": "usd", "when": {"eq": null}, "do": "review"}]
}

Keys

Key Meaning
http.method, http.url GET, POST, PUT, PATCH, DELETE, HEAD; an absolute http(s) URL. The host must be literal: it is checked against the allowlist before the run.
http.query, http.headers, http.body Objects. A JSON body is sent as application/json; a string that is exactly "{name}" keeps the argument's type (numbers stay numbers). GET/HEAD cannot have a body.
{name} Filled from args, in the URL path (URL-encoded), query, headers, body and response.select.
{{secret.name}} Filled from the request's secrets. Secrets are never written to trace, logs or provenance, and are masked before hashing.
args "$field" or "$field.path" takes another field's value and adds it to depends_on automatically; anything else is a constant.
http.response.select A path (result.content[0].text, items[*].title, data.0.id) or an object {name: path}. Missing paths give null.
http.response.not_found HTTP statuses that mean "no such thing": the value becomes null, not an error.
http.timeout Seconds (default 10, maximum 60).
http.retry {"attempts": 1-3} retries network errors and 5xx, for GET/HEAD only; other methods need "unsafe": true, so a retry never creates a second ticket by accident.
expose What later prompts may see: a list of keys, true for the whole value, or omitted = nothing (safe default). The result always holds the full value. Shown text is capped at 4000 characters.
on_error Network error, timeout, other status, oversized response or invalid select: null (default), review (flagged for a human) or reject (stop). The on rules still run on the null value.
at (in on rules) Check a path inside the value, e.g. {"at": "policy_status", "when": {"ne": "active"}, "do": "reject"}. Works on any field.

A call field cannot be nullable, extract, free_form, votes / vote_instructions, and list items cannot be calls. It has no confidence, because its value is not model output. It also cannot be thinking or an image context. Requests are capped at 1 MB per response and at 60 s per call, the total number of calls per run is bounded by the schema, and calls run under the server's allow_hosts policy (off by default).

How it runs

sequenceDiagram
    participant F as Flow
    participant T as Call thread
    participant M as Model (GPU)
    F->>T: stage starts: send HTTP requests
    F->>M: decode the other fields of the stage
    T-->>F: responses (select applied)
    M-->>F: decoded values
    F->>F: commit: on_error, on rules, then next stage

Calls start in background threads when their stage starts, while the model decodes the other fields of the same stage. Several calls in one stage run in parallel. A stage made only of calls does not touch the model. A call field has no confidence: it is not model output.

Prompts show a field only its upstream values (its depends_on, direct and indirect), and a call result only through expose. An expression may still reference a field of the same or an earlier stage without depending on it; that is how a model-read value is compared with an API value that the model never sees.

What is recorded

  • trace: one entry per call with method, host, path, status, ms, request_hash, response_hash and error.
  • provenance.calls: field, method, host, status and hashes only, because a path can contain personal data (for example a policy number).
  • Responses are read up to 1 MB. Server-Sent Events (text/event-stream, as used by MCP servers over HTTP) are decoded to their last JSON data: message.

Security

Call fields only work for hosts your administrator has allowed. Check with GET /health:

{"calls_enabled": true, "allow_hosts": ["api.coingecko.com", "mcp.exa.ai"]}
  • calls_enabled: false means call fields are switched off on this deployment; a schema with one is rejected with HTTP 400.
  • A URL whose host is not in allow_hosts is rejected with HTTP 400 before anything runs, and redirects to such hosts are refused. "*" means any host is allowed.

The allowlist exists because the API calls URLs from your schema from its network: without it, a schema could make the service reach internal systems that the caller cannot reach (server-side request forgery). Ask your administrator to add the hosts you need.

Secrets are sent per request, so every department uses its own keys and schemas can be stored and shared without them:

{"context": "...", "schema": {...}, "secrets": {"exa_key": "..."}}

Calling an MCP server over HTTP

An MCP server exposed over streamable HTTP is a JSON-RPC endpoint, so a call field can use it directly. Exa's public MCP endpoint works without a key:

"search_result": {
  "type": "call",
  "http": {
    "method": "POST", "url": "https://mcp.exa.ai/mcp",
    "headers": {"Accept": "application/json, text/event-stream"},
    "body": {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
             "params": {"name": "web_search_exa",
                        "arguments": {"query": "{coin} price today in USD", "numResults": 3}}},
    "timeout": 30,
    "response": {"select": "result.content[0].text"}
  },
  "args": {"coin": "$coin"},
  "expose": true
}

Lessons learned

Measured with trusttyped-v1.0.0:

  • Structured APIs beat search for numbers. A web search result still showed a stale Bitcoin price (about $64,575 while the price API said about $84,500); the model read it correctly and with confidence ~1.0. Confidence cannot catch a wrong source; a code check against the API (gt_expr / lt_expr) can.
  • Keep reference values out of the prompt. A field that should read a number from search results copied the API price instead while that price was in its prompt. Prompts now show only upstream values.
  • A failed call must stop the answer. When the price API hit its rate limit (HTTP 429) and the call used on_error: review, the answer field still ran and the model invented a price ("$64.300") from nothing. For data an answer is built on, use on_error: reject (plus a rule such as {"at": "usd", "when": {"eq": null}, "do": "reject"}) and give the answer field an honest on_reject. Keep review for data that only supports it.
  • Do not let the model reformat big numbers. Asked to add thousands separators, it wrote 1514071277 as "Rp15.140.712.77". Telling it to copy the digits exactly fixed it; format numbers in your own code.
  • Use enum to pick an argument. extract on "the asset the user asks about" returned the whole sentence, which is a valid span of the context; an enum of supported coins gives a clean argument for the URL.

Complete example: price lookup with a cross-check. In the playground: presets 24 (JSON API) and 25 (web search + cross-check).