The tool endpoint
Returning results
Whatever you return is fed to the model verbatim. It is not a database row on its way to a renderer — it is text the model reads and reasons over.
The contract
Return 200 with a JSON body. That is the whole requirement: any valid JSON is accepted, and
Tether does not impose a schema on tool results.
{
"tickets": [
{ "id": "T-4417", "table": 12, "total": 84.5, "opened_at": "2026-08-20T17:59:00Z" },
{ "id": "T-4421", "table": 4, "total": 31.0, "opened_at": "2026-08-20T18:12:00Z" }
],
"count": 2
}
The size ceiling
Tool responses are capped at 256 KB by default (ToolTransport:MaxResponseBytes). The
response is read with the cap enforced as it streams, so an oversized body is cut off rather
than buffered.
Exceeding it does not return partial data. The call fails with response_too_large, and the
model is told the lookup failed.
Cap and paginate on your side:
case 'get_open_tickets': {
const limit = Math.min(args.limit ?? 20, 100)
const rows = await listOpenTickets(tenant, limit)
return res.json({
tickets: rows,
returned: rows.length,
truncated: rows.length === limit, // tell the model it is not seeing everything
})
}
The truncated flag matters. Without it the model reports "you have 100 open tickets" when
the real number is 340.
Shape it for a reader, not for a renderer
Two results carrying identical information do not produce identical answers.
| ✗ | {"r":[[4417,12,84.5,1755712740],[4421,4,31.0,1755713520]]} |
| ✓ | {"tickets":[{"id":"T-4417","table":12,"total":84.5,"opened_at":"2026-08-20T17:59:00Z"}]} |
Named fields, ISO timestamps, and units the model does not have to infer. Positional arrays save bytes and cost accuracy — the wrong trade at this size.
Four things worth being explicit about:
- Units and currency.
"total": 84.5is ambiguous."total_usd": 84.5is not. - Timestamps in ISO 8601 with a zone. Unix seconds work, but the model reasons about "41 minutes ago" more reliably from a timestamp it can read.
- Empty results as empty, not as errors.
{"tickets": [], "count": 0}lets the model say "nothing is open". An error makes it say the lookup failed. - Numbers as numbers.
"84.50"as a string invites string comparison.
Return the minimum
Everything you return enters the model's context and may be repeated back to the user.
// This tool answers "how many tickets are open". It does not need to return card numbers.
return res.json({
tickets: rows.map(r => ({ id: r.id, table: r.tableNumber, total_usd: r.total }))
})
Selecting fields deliberately is the single cheapest privacy control you have, and it is
entirely on your side. Tether cannot know that customer.payment_profile should not have been
in the payload.
Reporting a failure
A tool that cannot answer should say so in the result, with 200. The model reads the message
and tells the user something useful.
return res.json({ error: 'Ticket T-4417 is already closed.' })
Compare that with returning a 500: the model is told only "that lookup failed", and the user
gets a shrug.
Reserve non-2xx for genuine transport-level failures. Note the retry consequence: 408, 429,
502, 503 and 504 are retried on read tools, and any other status is not — see
Retries, timeouts, idempotency.
Refusing a write
Your endpoint is the authority on permissions. A confirmed write you refuse is a normal outcome, not an exception:
if (!(await canVoid(tenant.user_id, ticket))) {
return res.json({ error: 'Voiding a ticket over $50 needs a manager.' })
}
Return 200 with that body and the model explains the rule. Return 403 and the confirmation
fails with a transport error instead.
Latency
The per-attempt timeout is 20 seconds (ToolTransport:TimeoutSeconds), and a tool call
sits inside a conversation turn where a person is waiting. Anything approaching that ceiling is
a poor tool: the model may call two or three in sequence.
If a capability is genuinely slow, model it as two tools — one that starts the work and returns a handle, one that reports its status — rather than one that blocks.