Connect Python functions as tools
A code tool is a Python function in your process. Psych keeps its name, its schema and its description. Your function stays yours: the decorator returns it unchanged, so your own...
A code tool is a Python function in your process. Psych keeps its name, its schema and its description. Your function stays yours: the decorator returns it unchanged, so your own code can still call it directly.
registry = psych_runtime.ToolRegistry()
@registry.register
async def lookup_order(order_id: str) -> dict[str, str]:
"""Look up an order by its id."""
return {"order_id": order_id, "status": "shipped"}
@registry.register(interruptible=False, safe_to_retry=False, annotations={"destructive"})
async def issue_refund(order_id: str, cents: int) -> str:
"""Refund an order."""
...Sync functions work too. Both forms of the decorator work: bare, or called with arguments.
Where the schema and description come from
The schema is derived from the function's type hints, so it cannot drift
from the code. The description is the first paragraph of the docstring,
because a tool the model cannot understand is a tool it will misuse. Missing
either one raises ToolSchemaError at registration, which is the right time:
publish-time is already too late to be cheap, and run time is far too late.
Argument models are built with extra="forbid", so a hallucinated argument is a
validation failure the model is told about rather than a silently dropped field.
The flags, and what each one prevents
| Flag | Default | Set it when |
|---|---|---|
name | function's name | The registered name should differ from the Python name. |
description | first docstring paragraph | The docstring is for your team and the model needs different words. |
interruptible | True | False for anything with a side effect that must not be half-done. An abort will not cancel it mid-call. A half-issued refund is worse than a slow stop. |
safe_to_retry | False | True only when re-executing is genuinely harmless. A Worker reclaiming a crashed Run may re-execute a call recorded as started. The default assumes a side effect is not repeatable, because assuming otherwise is how double refunds happen. |
annotations | frozenset() | {"read-only"}, {"write"} or {"destructive"}. Drives approval selectors. Unannotated is treated as write, never exempt. |
Granting a tool to an agent
Registration makes a tool available to the process. The Spec decides which agents may call it:
spec = psych_runtime.AgentSpec(
name="support",
model=psych_runtime.ModelRef(model="gpt-4o"),
tools=(
psych_runtime.CodeTool(name="lookup_order"),
psych_runtime.CodeTool(name="issue_refund", interruptible=False),
),
)CodeTool holds the name and nothing else. The Spec never holds the function.
Then hand the same registry to the Runtime, and pass its names to publish so a
typo fails at publish rather than mid-conversation:
runtime = psych_runtime.Runtime(store=store, model=model, registry=registry)
version = await psych_runtime.publish(
store, spec, context=psych_runtime.ValidationContext(registered_tools=registry.names)
)Tools whose shape is known only at runtime
For a tool generated from a database of integrations, a proxied non-MCP
catalogue or a customer-authored form definition, build the arguments model
yourself and use register_dynamic:
import functools
from pydantic import ConfigDict, create_model
arguments_model = create_model(
"lookup_salesforce_account_Arguments",
__config__=ConfigDict(extra="forbid"), # required, and checked
account_id=(str, ...),
)
registry.register_dynamic(
"lookup_salesforce_account",
functools.partial(call_integration, "lookup_salesforce_account"),
arguments_model,
description="Look up a Salesforce account by id.",
annotations={"read-only"},
)This produces the exact same RegisteredTool the decorator does. The resolver,
the narrowing intersection and the failure-streak guard read one shape and
cannot tell which door a tool came through. Two things it holds to the same
standard rather than relaxing:
- A description is required. A model chooses tools by their descriptions, so a schema with none is refused exactly like a docstring-less function.
extra="forbid"is required and checked, not silently added.create_modeldefaults to ignoring unknown fields. Rebuilding the model behind your back would drop anything not representable as bare field info: a@field_validator, a@model_validator, a computed field. So a model built without it is rejected at registration.
Reading the registry
registry.names is every registered name, which is what
ValidationContext(registered_tools=...) wants. registry.get(name) returns
the RegisteredTool or None.
Registered tools inside run_code
Every tool an agent is granted is callable from a program it writes, including these. The call routes through the same executor, Policy, guards and record log — a binding is the same tool reached a different way — and the program calls it by its own name:
total = sum((await lookup_order(order_id=oid))["qty"] for oid in ids)Two things about a registered tool matter more inside a program than outside:
safe_to_retrystill governs crash recovery. A Worker that dies with a binding call in flight recordsUNKNOWNand does not re-run it unless the registration said it was safe. Nothing about a program makes a side effect repeatable.- Annotations still decide approvals. A tool registered
destructiveis refused inside a program withapproval_required_in_programrather than approved silently, and the model is told to call it directly.
Gotchas
- A name colliding with a Psych built-in is refused at Spec validation.
Reserved:
load_skill,read_tool_output,remember,forget,ask_question,update_tasks,show_component,run_code, and the delegation and discovery tools. - Registering is not granting. A registered tool the Spec does not name is invisible to that agent. That is access narrowing working, not a bug.
- A tool that raises is not a crash. The failure is recorded and the model
is told, so it can try something else. Three consecutive failures of the same
tool trip the failure-streak guard; see
psych-agentsfor the thresholds. - Returning a large object is fine but has a cost. Results over
Limits.large_result_bytesare elided for the model and reachable throughread_tool_output. Seepsych-blobs.
Build a Spec with the fluent builder
One of four authoring forms and privileged over none of them. .build() produces exactly the AgentSpec or WorkflowSpec you would get from validating an equivalent dict, so it...
Call an HTTP endpoint as a tool
An HttpTool is a URL, a method, a JSON schema and a credential name. Entirely data, which means an end user can create one at runtime through your console with no code and no...