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

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.

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