Psych Runtime
Guides

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

This is the documentation for unreleased changes on main. For the released library, read v0.1.

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

FlagDefaultSet it when
namefunction's nameThe registered name should differ from the Python name.
descriptionfirst docstring paragraphThe docstring is for your team and the model needs different words.
interruptibleTrueFalse 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_retryFalseTrue 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.
annotationsfrozenset(){"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_model defaults 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_retry still governs crash recovery. A Worker that dies with a binding call in flight records UNKNOWN and 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 destructive is refused inside a program with approval_required_in_program rather 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-agents for the thresholds.
  • Returning a large object is fine but has a cost. Results over Limits.large_result_bytes are elided for the model and reachable through read_tool_output. See psych-blobs.

On this page