Loading the notebook…
Loading the notebook…
Notebooks · In-depth guides · Guide 03
A tool is a name, a description, a JSON Schema for its arguments, and code that runs. The model decides when to call one; shipit runs it, handles its errors, and feeds the result back.
Run end to end on shipit-agent 3.0 with real model calls (Haiku 5.5 on Anthropic, Gemma 4 31B on Bedrock). Every output below is from that run.
import os
from shipit_agent import Agent
from shipit_agent.llms import build_llm_from_env
from shipit_agent.llms.factory import build_llm_from_settings
os.environ["SHIPIT_ANTHROPIC_MODEL"] = "claude-haiku-5-5"
haiku = build_llm_from_env("anthropic")
gemma = build_llm_from_settings({"model": "google.gemma-4-31b"}, provider="bedrock")FunctionTool.from_callable reads the function's name, docstring and type hints. read_only=True tells the
permission gate it changes nothing, so it runs without asking.
import json
from shipit_agent.tools import FunctionTool
ORDERS = {
"A-1043": {"customer": "[email protected]", "total": 120.0, "status": "delivered"},
"A-1044": {"customer": "[email protected]", "total": 64.5, "status": "in transit"},
}
def find_order(order_id: str) -> str:
"""Look up an order by id (like A-1043) and return its customer, total and status."""
order = ORDERS.get(order_id)
return json.dumps(order) if order else f"no order {order_id}"
find = FunctionTool.from_callable(find_order, read_only=True)
print(json.dumps(find.schema(), indent=2)){
"type": "function",
"function": {
"name": "find_order",
"description": "Look up an order by id (like A-1043) and return its customer, total and status.",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "Argument order_id"
}
},
"required": [
"order_id"
]
}
}
}agent = Agent(llm=haiku, prompt="You are a support agent. Be brief.", tools=[find])
result = agent.run("Where is order A-1044, and how much was it?")
print(result.output)
for r in result.tool_results:
print(" ", r.name, "->", r.output)Order A-1044 is **in transit**. The total is **64.50**, and the customer on the order is [email protected]. The order record doesn't include a carrier or current location, so I can't say exactly where it is. find_order -> {"customer": "[email protected]", "total": 64.5, "status": "in transit"}
A class gives you a hand-written schema, prompt_instructions (guidance the model reads), and a
ToolOutput with metadata for your code that the model never sees.
from shipit_agent.tools.base import ToolContext, ToolOutput
class RefundTool:
name = "refund_order"
description = "Refund an order, in full or in part."
prompt_instructions = "Refund only orders that exist; a partial refund needs an amount."
read_only = False
def schema(self) -> dict:
return {"type": "function", "function": {
"name": self.name, "description": self.description,
"parameters": {"type": "object", "properties": {
"order_id": {"type": "string", "description": "The order to refund, e.g. A-1043."},
"amount": {"type": "number", "description": "Dollars to refund; omit for a full refund."},
}, "required": ["order_id"]},
}}
def run(self, context: ToolContext, **kwargs) -> ToolOutput:
order_id, amount = kwargs["order_id"], kwargs.get("amount")
if order_id not in ORDERS:
return ToolOutput.error(f"{order_id} is not an order. Order ids look like A-1043; look it up first.")
refunded = ORDERS[order_id]["total"] if amount is None else amount
return ToolOutput(text=f"Refunded ${refunded:.2f} on {order_id}.",
metadata={"order_id": order_id, "refunded": refunded})
refund = RefundTool()
print(refund.name, "| read_only:", refund.read_only)refund_order | read_only: False
ToolOutput.error(...) goes back to the model as a failed call with your message, and the model adjusts.
Here it's given an order number in the wrong format.
agent = Agent(llm=haiku, prompt="You are a support agent. Be brief.", tools=[find, refund],
permission_mode="bypassPermissions")
result = agent.run("Refund order 1043 in full.")
for r in result.tool_results:
print(f"{r.name:13} {r.output}")
print("\n" + result.output)find_order {"customer": "[email protected]", "total": 120.0, "status": "delivered"}
refund_order Refunded $120.00 on A-1043.
Order A-1043 has been refunded in full: $120.00.The run above used permission_mode="bypassPermissions" so the refund ran without asking; Guide 09 covers
permissions. Structured data from the tool is in metadata, which the model never sees:
print([r.metadata for r in result.tool_results if r.name == "refund_order"])[{'order_id': 'A-1043', 'refunded': 120.0}]A raised exception never ends the run. The model sees Error running tool '<name>': <message> and carries on.
def inventory(sku: str) -> str:
"""Return stock for a SKU."""
raise ConnectionError("inventory service is down")
agent = Agent(llm=haiku, prompt="Be brief.", tools=[FunctionTool.from_callable(inventory, read_only=True)])
result = agent.run("How many SKU-7 are in stock?")
print([r.output for r in result.tool_results])
print(result.output)["Error running tool 'inventory': inventory service is down"] I couldn't get the stock count for SKU-7 because the inventory service is down. Please try again later.
ToolOutput.from_records(rows, fields=..., limit=...) shows the model a page of the fields it needs, with the
total count, and keeps every record in text for your code.
rows = [{"id": f"T-{i}", "status": "open" if i % 3 else "closed", "subject": f"Ticket {i}", "body": "…" * 50}
for i in range(1, 121)]
out = ToolOutput.from_records(rows, fields=["id", "status"], limit=5)
print("model sees:\n" + out.model_text)
print("\nfull text:", len(out.text), "characters")model sees:
{"records": [{"id": "T-1", "status": "open"}, {"id": "T-2", "status": "open"}, {"id": "T-3", "status": "closed"}, {"id": "T-4", "status": "open"}, {"id": "T-5", "status": "open"}], "total_records": 120, "offset": 0, "returned_records": 5, "next_offset": 5, "selected_fields": ["id", "status"]}
full text: 14504 charactersmax_tool_output_chars (16,000 by default) bounds what the model sees of one result, because every later turn
re-sends it. result.tool_results always keeps the whole output.
def server_log() -> str:
"""Return today's server log."""
return "\n".join(f"{12 + i // 3600}:{i // 60 % 60:02d}:{i % 60:02d} GET /api/orders 200" for i in range(4000)) + "\n13:07:00 ERROR db timeout"
agent = Agent(llm=haiku, prompt="Be brief.", tools=[FunctionTool.from_callable(server_log, read_only=True)],
max_tool_output_chars=2000)
result = agent.run("Is there an error in today's server log? Quote it if so.")
print("full output kept:", len(result.tool_results[0].output), "characters")
print(result.output)full output kept: 116025 characters Yes. The end of the log shows: `13:07:00 ERROR db timeout` The tool output was truncated, though: about 114,000 of 116,000 characters were omitted from the middle, so I haven't checked the full log. The visible parts show only 200 responses from `GET /api/orders`. The tool takes no filters, so I can't search the omitted section directly.
The cap keeps the start and the end of a long result and tells the model how much was left out, so it saw the last line here, and knows it may have missed errors in the middle. Better still, make tools return the part that matters (here: only the error lines).
When the model asks for several read-only tools in one response, they run at the same time
(parallel_tool_execution=True, the default). Two lookups that each take one second:
import time
def weather(city: str) -> str:
"""Current weather for a city."""
time.sleep(1)
return {"Lisbon": "21°C, sunny", "Oslo": "4°C, rain"}.get(city, "unknown")
tool = FunctionTool.from_callable(weather, read_only=True)
for parallel in (True, False):
agent = Agent(llm=haiku, prompt="Call tools for each city at once.", tools=[tool], parallel_tool_execution=parallel)
started = time.perf_counter()
result = agent.run("Weather in Lisbon and in Oslo?")
print(f"parallel={parallel!s:5} {time.perf_counter() - started:4.1f}s {len(result.tool_results)} calls")parallel=True 2.9s 2 calls
parallel=False 3.8s 2 calls
Agent.with_builtins() attaches shipit's own tools: files, shell, search, the web, planning, documents and
more. Point project_root at a folder to work in.
from pathlib import Path
project = Path("demo_project")
(project / "src").mkdir(parents=True, exist_ok=True)
(project / "src" / "pricing.py").write_text(
"def price(seats: int) -> float:\n return seats * 12.0 # Starter, per seat per month\n")
(project / "README.md").write_text("# Demo\nA tiny pricing module.\n")
builder = Agent.with_builtins(llm=haiku, project_root=str(project))
print(len(builder.tools), "tools:", ", ".join(sorted(t.name for t in builder.tools))[:400], "…")64 tools: ask_user, ask_user_async, bash, bash_job, build_artifact, build_document, build_prompt, confluence, connections, create_app, custom_api, decision_matrix, decompose_problem, deep_research, describe_binding, download_file, edit_file, figma, git_ops, github, gitlab, give_up, glob_files, gmail_search, google_calendar, google_drive, google_sheets, grep_files, human_review, image_generate, jira, linear, …
result = builder.run("What does price() charge per seat? Answer from the code, citing the file and line.")
print([r.name for r in result.tool_results])
print(result.output)['grep_files', 'read_file'] `price()` charges **$12.00 per seat per month**. It returns `seats * 12.0`, with the comment "Starter, per seat per month" (`src/pricing.py:2`). The function is defined at `src/pricing.py:1`.
Fewer tools mean smaller requests and fewer wrong choices. Pass exactly the classes you want, or keep the
built-ins and remove some with disallowed_tools. Removed tools are hidden from the model when the run
starts, so the count to check is the one each step reports.
from shipit_agent.tools import FileReadTool, GrepSearchTool
reader = Agent(llm=haiku, project_root=str(project),
tools=[FileReadTool(root_dir=str(project)), GrepSearchTool(root_dir=str(project))])
print("reader:", [t.name for t in reader.tools])
def tools_seen(agent: Agent) -> int:
for e in agent.stream("Reply with one word: ready"):
if e.type == "step_started":
return e.payload.get("tool_count", 0)
return 0
everything = Agent.with_builtins(llm=haiku, project_root=str(project), deferred_tools=False)
no_shell = everything.clone(disallowed_tools=["bash", "bash_job", "playwright_browse"])
print("tools the model sees, all built-ins:", tools_seen(everything))
print("tools the model sees, without shell and browser:", tools_seen(no_shell))reader: ['read_file', 'grep_files']
tools the model sees, all built-ins: 64
tools the model sees, without shell and browser: 62
Every tool's schema is sent with every request. Above ten tools, shipit keeps a small core loaded and lists
the rest by name (deferred_tools="auto"); the model loads one with tool_search when it needs it.
for mode in (False, True):
agent = Agent.with_builtins(llm=haiku, project_root=str(project), deferred_tools=mode)
result = agent.run("Reply with one word: ready")
print(f"deferred_tools={mode!s:5} input tokens: {result.metadata['usage']['prompt_tokens'] + result.metadata['usage'].get('cache_read_input_tokens', 0) + result.metadata['usage'].get('cache_creation_input_tokens', 0)}")deferred_tools=False input tokens: 38583
deferred_tools=True input tokens: 6819
Tool events show each call with its arguments, its output and how long it took.
for e in Agent(llm=haiku, prompt="Be brief.", tools=[find]).stream("Status of A-1043 and A-1044?"):
if e.type == "tool_called":
print(f"call {e.payload['tool']}({e.payload['arguments']})")
elif e.type == "tool_completed":
print(f"done {e.payload['tool']} -> {e.payload['output'][:50]}… [{e.payload.get('duration_ms', 0):.1f} ms]")
elif e.type == "run_completed":
print(e.payload["output"])call find_order({'order_id': 'A-1043'})
call find_order({'order_id': 'A-1044'})
done find_order -> {"customer": "[email protected]", "total": 120.0, "s… [2.9 ms]
done find_order -> {"customer": "[email protected]", "total": 64.5, "st… [3.4 ms]- **A-1043:** Delivered (customer [email protected], total $120.00) - **A-1044:** In transit (customer [email protected], total $64.50)
Section 5 on Gemma 4 31B (Bedrock): the bad order id, the error, and the fix.
agent = Agent(llm=gemma, prompt="You are a support agent. Be brief.", tools=[find, refund],
permission_mode="bypassPermissions")
result = agent.run("Refund order 1043 in full.")
for r in result.tool_results:
print(f"{r.name:13} {r.output}")
print("\n" + result.output)refund_order 1043 is not an order. Order ids look like A-1043; look it up first.
find_order {"customer": "[email protected]", "total": 120.0, "status": "delivered"}
refund_order Refunded $120.00 on A-1043.
Order A-1043 has been successfully refunded in full ($120.00).import shutil
shutil.rmtree(project)FunctionTool.from_callable(fn, read_only=...) turns a typed function into a tool.prompt_instructions, a hand-written schema and ToolOutput with metadata.ToolOutput.error(...) lets the model fix its call; a raised exception is reported, never fatal.model_text / from_records / max_tool_output_chars keep big results small for the model.with_builtins() adds the built-in set; deferred_tools keeps requests small.Next: Guide 04 — MCP servers.