Level 10Lesson 82โฑ๏ธ 60 min

Tool Design as API Design

The model is a client of your API. Design the contract like you would for any other client, then let the platform enforce it.

What you will be able to do

  • Write tool schemas that strict mode accepts, so the API guarantees every call matches
  • Return one result envelope the model can branch on: ok, data, and an error with a retryable flag
  • Split read tools from write tools and give every write an idempotency key, so lesson 86 can gate writes and lesson 91 can retry them
Companion repo: posixplus/learn2exel-dayflow, tag lesson-82. Diff it against lesson-81 to see exactly what this lesson changes: git diff lesson-81 lesson-82 -- dayflow/tools.py.

What lesson 81's trajectory told us

The first live brief called read_calendar(days=7) when "Today" needed one day, because the prompt also asked about the week. The model made a reasonable guess. That is the problem: a tool whose correct use depends on guessing is an API with an undocumented contract, and every client will guess differently.

Three other things were loose in lesson 81. Results were raw JSON with no consistent shape, so an error looked like {"error": "..."} while success looked like a list. Every tool was a read, so nothing distinguished "look at the inbox" from "send an email". Anddispatch() raised on a bad argument instead of telling the model what was wrong.

Rule 1: strict schemas, enforced by the platform

Add "strict": true to a tool and the API guarantees the call's name and arguments match input_schema. No more "the model forgot a field". Strict mode has three requirements, and Dayflow's Tool class produces them so you cannot forget:

@dataclass
class Tool:
    name: str
    description: str
    kind: Literal["read", "write"]
    properties: dict[str, Any]
    handler: Callable[..., Any]

    def schema(self) -> dict:
        return {
            "name": self.name,
            "description": self.description,
            "strict": True,
            "input_schema": {
                "type": "object",
                "properties": self.properties,
                "required": list(self.properties),   # 1. every property required
                "additionalProperties": False,       # 2. no surprise keys
            },
        }
        # 3. optional values use a null type union, not omission (see create_task.due)
โš ๏ธ
Caught live: strict mode rejects numeric constraints
The first draft of this lesson had "limit": {"type": "integer", "minimum": 1, "maximum": 50}. The API returned 400: For 'integer' type, properties maximum, minimum are not supported. Under strict mode, minimum, maximum, pattern and length limits are all rejected. State the bound in the description and enforce it in the handler (max(1, min(50, limit))). There is a test for this now so it cannot regress.

Rule 2: make the wrong call impossible, not just unlikely

Lesson 81's calendar took days: integer. Lesson 82's takesrange: "today" | "week". An enum removes the guess and removes the abuse case (days=400) in one move.

READ_CALENDAR = Tool(
    name="read_calendar",
    kind="read",
    description="List calendar events. Use 'today' for the day's schedule, 'week' for deadlines and planning.",
    properties={"range": {"type": "string", "enum": ["today", "week"]}},
    handler=lambda range: calendar.list_events(days=1 if range == "today" else 7),
)

Watch what the model did with it in the live run below: it called read_calendartwice, once per value, because the brief needs both. That is the contract working. The description tells it which value serves which purpose, so it does not have to infer.

Descriptions are documentation for a client that reads fast

  • Say what the tool returns, not just what it does: "Returns id, from, subject, date, body."
  • Say what it includes that the caller might not want: "Promotions and automated reminders are included; the caller decides what matters."
  • Say when not to call it: "Only call this when the brief identifies a concrete action the user has not already tracked."
  • Give a worked example for anything the model has to construct: 'reply-priya-vendor-2026-09-07' for an idempotency key.

Rule 3: one result envelope, errors included

Every tool returns the same shape. The model learns it once and can branch on it; your evals (lesson 89) can parse it; your tracing (lesson 90) can count it.

{"ok": true,  "data": [...]}
{"ok": false, "error": {"code": "invalid_due", "message": "due must be YYYY-MM-DD, got '10/09/2026'", "retryable": false}}
{"ok": false, "error": {"code": "tool_failed", "message": "TimeoutError: ...", "retryable": true}}

dispatch() never raises. A handler that wants to return a structured error raisesToolError(code, message, retryable=...); anything else that escapes becomestool_failed with retryable: true. The retryable flag is the hook lesson 91 uses to decide whether to try again or degrade.

def dispatch(name: str, args: dict) -> str:
    tool = REGISTRY.get(name)
    if tool is None:
        return _err("unknown_tool", f"no tool named {name!r}", retryable=False)
    try:
        return _ok(tool.handler(**args))
    except TypeError as e:
        return _err("bad_arguments", str(e), retryable=False)
    except ToolError as e:
        return _err(e.code, e.message, retryable=e.retryable)
    except Exception as e:
        return _err("tool_failed", f"{type(e).__name__}: {e}", retryable=True)

The system prompt says what to do with a failure: "If ok is false, say which source was unavailable in the brief; do not guess at its contents." Contract on the API side, policy on the prompt side. Keep them separate; the contract rarely changes, the policy does.

Rule 4: reads and writes are different animals

Dayflow's first write tool is create_task. It is tagged kind="write", and definitions(("read",)) produces a surface without it. That one line is the foundation of lesson 86's approval gates and lesson 88's least-privilege argument: a summariser that only has read tools cannot be injected into sending anything.

Idempotency: the write you can safely retry

def create_task(self, *, title, due, idempotency_key):
    if idempotency_key in self._by_key:
        return {**self._by_key[idempotency_key], "created": False}   # same key, same task, no duplicate
    task = {"id": f"t{len(self._tasks) + 1}", "title": title.strip(), "due": due, "done": False}
    self._tasks.append(task)
    self._by_key[idempotency_key] = task
    return {**task, "created": True}

The model supplies the key and the description tells it how: a stable string for theintent, not a random one. In the live run it chosesam-onepager-edits-2026-09-07. If the call times out and the loop retries it (lesson 91), the second call returns created: false and nothing is duplicated. Without the key, a retried send is a double send, and that is the single most common production bug in agentic apps.

The live run on lesson-82

$ dayflow brief -v
[step 1] stop=tool_use
    -> read_inbox({'limit': 20}) 1269 chars
    -> read_calendar({'range': 'today'}) 153 chars
    -> read_calendar({'range': 'week'}) 718 chars
    -> read_tasks({'include_done': False}) 294 chars
[step 2] stop=tool_use
    -> create_task({'title': 'Send edits on partnership one-pager to Sam',
                    'due': '2026-09-08',
                    'idempotency_key': 'sam-onepager-edits-2026-09-07'}) 142 chars
    -> create_task({'title': 'Return signed field trip permission form',
                    'due': '2026-09-11',
                    'idempotency_key': 'school-field-trip-form-2026-09-07'}) 140 chars
[step 3] stop=end_turn
...
- Fri Sep 11: Return the signed field trip permission form. I created this task.
  The school email says "Friday, September 12", but Sep 12 is a Saturday, so I
  used Friday the 11th. Check the date with the school.

[3 steps ยท 6 tool calls ยท 7616 in / 1731 out]
  • Two calendar calls, one per enum value. The contract, not a guess, decided that.
  • Two writes, under the prompt's cap of two, each with a readable idempotency key the model composed from the description's example.
  • It found two bugs in the fixtures. "Friday, September 12" (a Saturday) and "Tue Sep 9" (a Wednesday). The model is a client of your API, and clients find your bugs. The fixtures are fixed at this tag.
  • Cost went up from 4.4K to 9.3K tokens: one more step, two more results in history. Writes are not free even when they are cheap.
๐Ÿ’ก
Read-only mode is one flag away
dayflow brief --read-only passes definitions(("read",)). Same prompt, nocreate_task on the surface, so the model cannot create anything however it is asked. That is capability restriction, and it is the defense lesson 36 and lesson 88 both lean on.
๐Ÿ–ฅ๏ธHANDS-ON EXERCISEโฑ 25 min

Break the contract on purpose

Each step takes the contract away in one place and shows what the platform or the loop does about it.

  1. Run `pytest`. Sixteen tests now: the original six plus ten that pin the tool contract (strict compatibility, no numeric constraints, read-only surface, envelope shapes, idempotency, due-date validation).
  2. In dayflow/tools.py, add `"minimum": 1` back to the `limit` property. Run `pytest` (one test fails) and then `dayflow brief` (the API rejects the request with a 400 before any tokens are spent). Revert.
  3. Change `range` from an enum to `{"type": "string"}`. Run the brief three times and note the values the model invents. Revert, and notice why the enum was never about validation alone.
  4. Run `dayflow brief --read-only`. Confirm no create_task appears in the trajectory. Then edit the user message to "Create a task reminding me to buy milk" and run read-only again. The model should tell you it cannot; if it claims it did, that is a prompt bug worth a test.
  5. In the fixture adapter, make `create_task` raise `ToolError("quota", "task quota exceeded", retryable=True)`. Run the brief. The brief should still complete and mention the failure. Read the envelope the model received in the verbose output.
  6. TypeScript mirror: `cd ts && npm run brief -- -v`. Diff ts/src/tools.ts against dayflow/tools.py. The registry, envelope and strict schema are structurally identical; the TS SDK types the schema for you.

What this lesson deliberately does not do

  • Tool search / deferred loading. With four tools, every definition goes in every request. Past forty or so, you stop shipping all of them and give the model a search_tools tool instead. Dayflow will not get there; lesson 92 shows the MCP equivalent.
  • Persisting the created tasks. They live in memory for the run and vanish. Lesson 83.
  • Asking before writing. The model created two tasks without asking. For tasks that is fine; for emails it is not. Lesson 86.
Lesson 82 Quick Reference
strict: true

API guarantees name + arguments match input_schema. Needs every property required and additionalProperties: false.

Strict mode rejects

minimum, maximum, pattern, minLength, maxLength. Put bounds in the description, enforce in the handler.

Enum over integer

range: "today" | "week" removes the guess and the abuse case at once.

Result envelope

{ok: true, data} or {ok: false, error: {code, message, retryable}}. dispatch() never raises.

ToolError

Handlers raise it to return a typed error; everything else becomes tool_failed, retryable: true.

kind: read | write

definitions(("read",)) is the read-only surface. Foundation for approval gates and least privilege.

Idempotency key

Stable per intent, supplied by the model from the description's example. Same key, same result, no duplicate.

Unlocks in ~15 min of reading