Tether
DocsGitHub

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.5 is ambiguous. "total_usd": 84.5 is 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.