Course Content
Live Coding Interview Prep
7 sections · 50 lessons
Implement a function-calling handler for LLM APIs.
What you need to know
Function calling (also called tool use) means the model does not run anything. It returns a structured request — "call get_weather with {"city": "Pune"}" — and your code decides whether and how to run it. Four parts:
- Schema. A name, a description the model reads to decide when to call it, and a JSON Schema for the arguments.
additionalProperties: falseplus arequiredlist, withstrict: trueon the tool, makes the API guarantee the arguments match. - Dispatch. Name to function lookup, with errors caught.
- Result protocol. For the Anthropic Messages API: the assistant turn contains
tool_useblocks, each with anid. You reply with a user turn oftool_resultblocks carrying the sametool_use_id. - Parallel calls. One reply can contain several
tool_useblocks. All their results go back in one user message.
inspect.signature gives you each parameter's name, type annotation and default, which is everything a schema needs.
1import inspect, json2from collections.abc import Callable3from typing import Any45REGISTRY: dict[str, dict[str, Any]] = {}6_JSON_TYPES = {str: "string", int: "integer", float: "number",7 bool: "boolean", list: "array", dict: "object"}89def tool(fn: Callable) -> Callable:10 """Register fn as a tool; its schema comes from its signature and docstring."""11 props, required = {}, []12 for name, p in inspect.signature(fn).parameters.items():13 props[name] = {"type": _JSON_TYPES.get(p.annotation, "string")}14 if p.default is inspect.Parameter.empty:15 required.append(name)16 REGISTRY[fn.__name__] = {"fn": fn, "spec": {17 "name": fn.__name__,18 "description": inspect.getdoc(fn) or "",19 "input_schema": {"type": "object", "properties": props,20 "required": required, "additionalProperties": False},21 "strict": True,22 }}23 return fn2425def dispatch(name: str, args: dict) -> tuple[bool, Any]:26 """Run a registered tool. Returns (ok, result_or_error_message)."""27 entry = REGISTRY.get(name)28 if entry is None:29 return False, f"unknown tool: {name}"30 try:31 return True, entry["fn"](**args)32 except TypeError as exc:33 return False, f"bad arguments: {exc}"34 except Exception as exc:35 return False, f"{type(exc).__name__}: {exc}"3637@tool38def get_weather(city: str, unit: str = "C") -> str:39 """Return the current weather for a city."""40 return f"{city}: 31{unit}, humid"The loop, with the Anthropic Python SDK:
1from anthropic import Anthropic23client = Anthropic()45def chat(user_msg: str, max_turns: int = 8) -> str:6 """Run the tool loop until the model stops asking for tools."""7 messages = [{"role": "user", "content": user_msg}]8 for _ in range(max_turns):9 resp = client.messages.create(10 model="claude-opus-5", max_tokens=16000,11 tools=[t["spec"] for t in REGISTRY.values()], messages=messages,12 )13 messages.append({"role": "assistant", "content": resp.content})14 if resp.stop_reason != "tool_use":15 return "".join(b.text for b in resp.content if b.type == "text")16 results = []17 for block in resp.content:18 if block.type == "tool_use":19 ok, out = dispatch(block.name, block.input)20 results.append({"type": "tool_result", "tool_use_id": block.id,21 "content": json.dumps(out, default=str), "is_error": not ok})22 messages.append({"role": "user", "content": results}) # all results, one message23 raise RuntimeError("tool loop did not finish within max_turns")The tricky parts:
- Append
resp.content, not just its text. The assistant turn must contain thetool_useblocks (and any thinking blocks) exactly as returned, or the API cannot match yourtool_resultids. - Every
tool_usegets atool_result, even on failure — a missing one makes the next request fail.is_error: Truetells the model the call failed. - One user message for all results. Splitting parallel results across several messages works, but it teaches the model to stop making parallel calls.
json.dumps(out, default=str)so a tool returning adatetimeorDecimaldoes not crash serialisation.
Complexity: building schemas is O(parameters) once at import. Dispatch is an O(1) dict lookup plus the tool's own cost. The loop is O(turns) API calls, capped by max_turns.
A real-life example
1print(json.dumps(REGISTRY["get_weather"]["spec"]["input_schema"]))2# (one line, wrapped here)3# {"type": "object", "properties": {"city": {"type": "string"}, "unit": {"type": "string"}},4# "required": ["city"], "additionalProperties": false}5print(dispatch("get_weather", {"city": "Pune"}))6# (True, 'Pune: 31C, humid')7print(dispatch("get_weather", {}))8# (False, "bad arguments: get_weather() missing 1 required positional argument: 'city'")9print(dispatch("book_flight", {"to": "Goa"}))10# (False, 'unknown tool: book_flight')Trace of one full turn where the user asks "Weather in Pune and Delhi?":
| message | content |
|---|---|
| user | Weather in Pune and Delhi? |
| assistant | tool_use id tu_1 get_weather(Pune), tool_use id tu_2 get_weather(Delhi) — stop_reason is tool_use |
| user | tool_result for tu_1: "Pune: 31C, humid"; tool_result for tu_2: "Delhi: 31C, humid" |
| assistant | "Both cities are at 31C and humid." — stop_reason is end_turn, loop returns |
unit is not in required because it has a default, so the model may omit it.
A travel app's assistant uses exactly this handler for search_trains, check_pnr and get_fare, with the schemas generated from the Python functions its backend team already has.
Follow-up questions to expect
- "How do you validate arguments beyond types?" — Use Pydantic models as the tool's parameter type and generate the schema from them (
model_json_schema()), then validate before calling.strict: trueguarantees shape; business rules (a date in the future) are still your code's job. - "How do you run parallel tool calls?" — Dispatch the blocks concurrently with a thread pool or
asyncio.gather, then return all results in one message in any order; the ids do the matching. - "What if a tool takes 30 seconds?" — Give every tool a timeout, and return an error result on timeout rather than blocking the loop.