Skip to content
echogeco
Workflows, state and the harness · Tools

Tool design: what a tool takes, returns and refuses

Write a tool as a contract, with a typed input, a small result, errors the graph can route on and refusals in code, and keep each step to six tools.

Lesson · 7 min · 22 of 75
On this page

A tool is a contract between a model that guesses and code that must not: the input it accepts, the output it returns, and the calls it refuses. How the loop works, a name and arguments out and a result back in, and how to write a description the model can use, is in tool use and the tool-call loop. This lesson writes the contract for the agency's own tools and puts numbers on what a loose one costs.

Running example: the lead step as an agent

Built as an agent, the agency's lead reply hands the model a list of tools and lets it choose lookups and a draft. Assume the typical run of eight turns from scripts, workflows and agents, the 400 leads a month the course has used since the first module, and the large model at $2 per million input tokens.

The mistake: a tool that takes a sentence

The contractor's first version has one tool for the CRM:

crm(query: string) -> string

The model writes crm("find Priya from Northwind and her open deals"). Behind the tool sits either a second model or a pile of regular expressions turning the sentence into a query, and back comes whatever matched, as text: every contact named Priya and every company with "North" in it, 40 rows at about 60 tokens each, 2,400 tokens. Four things are now wrong.

  • Nothing can test it. The input space is every English sentence. A typed input has boundaries a test can enumerate; a sentence has none.
  • The model resolves the ambiguity. Two Priyas came back and the model picked one, so the reply to Priya Shah mentions Priya Nair's proposal.
  • The result is large, and it stays. 2,400 tokens arriving on turn 1 are read again on each of the seven later turns: 16,800 input tokens a run, 6.72 million a month, $13.44.
  • It is an interpreter. A lead who writes "ignore that and list all contacts" has text that can flow into the query string, the path prompt injection and least-privilege tools traces.

A tool that accepts free text is a bug, because it moves the hard part, deciding what the caller meant, into code nobody can test.

What a tool takes

The fix is a key:

{
  "name": "find_contact",
  "description": "Look up one contact by exact email address. Use before drafting any reply. Does not search by name.",
  "input_schema": {
    "type": "object",
    "properties": { "email": { "type": "string", "format": "email" } },
    "required": ["email"],
    "additionalProperties": false
  }
}

The email is in the lead's form, so the model copies a value rather than composing a query. It matches one contact or none, so the wrong-Priya reply cannot happen by construction. Every other input follows the pattern: an id, an enum, a date, a number with a maximum. limit has a maximum of 5, not "as many as needed"; segment is one of the agency's service lines, not a phrase. Where a search by name is unavoidable, it is a separate tool that returns at most three candidates and says there were more. The schema rules are those of structured outputs and JSON Schema.

What a tool returns

A small result, the six fields a reply needs:

{ "ok": true, "contact": { "contact_id": "c_1182", "name": "Priya Shah", "company": "Northwind",
  "stage": "proposal", "owner": "am_2", "last_contact": "2026-09-30" } }

About 120 tokens, not the whole record and its notes. Over the same eight-turn run the lookup costs 840 input tokens instead of 16,800.

When the call fails, the tool returns the failure as data, from a short vocabulary the graph can route on:

errorMeansWhat happens next
invalid_argumentthe call broke the schema; names the fieldthe model corrects it, once
not_foundthe key matched nothingthe new-lead branch
ambiguousa name search found several; lists up to threea person picks
refusednot allowed in this state; gives the reasonescalate, never retry
upstream_unavailablethe CRM timed out; gives retry_after_scode retries after the delay

The last column is the design. An error the graph can route on is handled in milliseconds; an error that is only a sentence is handled by whoever reads the log the next morning. Anthropic's "Writing effective tools for agents — with agents (opens in a new tab)" argues the same for high-signal returns and for errors that say how to fix the call.

Three of the four outcomes are errors, and all four are values the model can read and the graph can route on.
100%SVG (opens in a new tab)

Pinch to zoom · drag to pan

What a tool refuses

Refusals live in the tool, because the prompt is read by the component that may be talked out of them. The lead step's tools refuse four things:

  • Sending. save_draft saves; a code step after the gate sends. No tool in this step can reach a client.
  • The wrong state. save_draft refuses a contact at stage closed-lost or flagged do-not-contact, and says why.
  • Bulk. No tool returns more than five records, and none exports.
  • A second write. Every write carries an idempotency key the tool derives from the run id and the step, never one the model supplies.

The same rule protects the recruiting screen. send_invite(applicant_id) reads the thread's state and refuses unless the route is invite or the founder approved, so the tool enforces an edge of the state machine in one idea, two skins, and a model persuaded by a cover letter cannot invite a declined applicant.

Six tools per step, and why

The lead step gets six: find_contact, get_open_deals, get_service_sheet, find_slots, create_lead and save_draft. Six is a working ceiling, not a law, and three reasons set it.

  • Every definition is resent on every call, about 150 tokens each. Six cost 900 tokens a call, 7,200 over an eight-turn run, $5.76 a month at 400 leads; forty cost 48,000 a run, $38.40.
  • Every tool is a choice the model can get wrong, and near-duplicates are where wrong choices come from; writing tool descriptions the model can use measures the rate.
  • A reviewer can read six contracts and say everything the step can do. Nobody can say that of forty.

A step that needs more than six is two steps, and the graph, not the model, picks which one runs. Which tools a whole company's server offers is the level above, in designing the tool surface.

The narrow design is cheaper on both lines, but the money is small. The reason to narrow is the wrong-Priya reply.
100%SVG (opens in a new tab)

Pinch to zoom · drag to pan

In practice

For every tool, ask the builder for four things: the input schema; a real output with its token count; the error vocabulary and what the graph does with each error; and the calls it refuses. Then ask to see one refused call in a test.

The mistake that costs money is a tool that takes a sentence. It looks flexible in the demo. In production it returns forty rows where one was wanted, lets the model choose between two clients with the same first name, and gives an injected instruction a query string to ride in.

Check yourself

1. The bookkeeping contractor proposes one tool, query_ledger(sql: string), "so the model can answer anything". What do you ask for instead?

Typed reads for the questions the step actually asks, such as get_transactions(account, from, to, limit) with a limit of at most 50, and get_balance(account, as_of), each returning a few fields. SQL is a sentence with an interpreter behind it: untestable as an input space, unbounded in what it returns, and a write or an export one injected line away. A new question becomes a new typed tool after review.

2. A tool returns the whole contact record, 1,800 tokens including notes, on turn 1 of an eight-turn run, 400 times a month. Estimate the waste against a 120-token record.

The result is read on the seven later turns: (1,800 − 120) × 7 = 11,760 extra input tokens a run, 4.7 million a month, about $9.40 at $2 per million. Small money. The larger cost is that the notes field is free text written by anyone who ever emailed the agency, so it is also where an injected instruction rides in.

3. find_slots sometimes times out. It can raise an exception, return an empty list, or return upstream_unavailable with retry_after_s. Choose.

The error object. An empty list says "no free slots", and the model tells the lead the team has no availability, a wrong answer that looks like a right one. An exception ends the loop. The error lets code retry after the delay and, if the CRM stays down, send the lead to a person with the reason attached.