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.
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...