Psych Runtime
Reference

Spec models

What an agent or workflow is, as data. None of these holds a callable.

Spec

value

def Spec(*args, **kwargs)

Runtime representation of an annotated type.

At its core 'Annotated[t, dec1, dec2, ...]' is an alias for the type 't' with extra annotations. The alias behaves like a normal typing alias. Instantiating is the same as instantiating the underlying type; binding it to types is also the same.

The metadata itself is stored in a 'metadata' attribute as a tuple.

AgentSpec

model

class AgentSpec(*, kind: Literal['agent'] = 'agent', name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', instructions: str = '', model: psych_runtime.core.spec.ModelRef, tools: tuple[typing.Annotated[psych_runtime.core.spec.CodeTool | psych_runtime.core.spec.HttpTool, FieldInfo(annotation=NoneType, required=True, discriminator='kind')], ...] = (), mcp_servers: tuple[psych_runtime.core.spec.McpServer, ...] = (), a2a_peers: tuple[psych_runtime.core.spec.A2APeer, ...] = (), skills: tuple[psych_runtime.core.spec.Skill, ...] = (), subagents: tuple[psych_runtime.core.spec.SubagentRef, ...] = (), spawn: psych_runtime.core.spec.SpawnEnvelope | None = None, compaction: psych_runtime.core.spec.CompactionPolicy | None = None, limits: psych_runtime.core.spec.Limits = <factory>, suspension: psych_runtime.core.spec.SuspensionPolicy = <factory>, tasks_enabled: bool = False, components_enabled: bool = False, answer_style: Optional[Literal['concise']] = None)

A Step that loops Turns until a stop condition (DESIGN.md §5).

FieldTypeNotes
kindLiteral
namestr
descriptionstr
instructionsstr
modelModelRef
toolstuple
mcp_serverstuple
a2a_peerstuple
skillstuple
subagentstuple
spawn`spec.SpawnEnvelopeNone`
compaction`spec.CompactionPolicyNone`
limitsLimits
suspensionSuspensionPolicy
tasks_enabledbool
components_enabledbool
answer_styleOptional

WorkflowSpec

model

class WorkflowSpec(*, kind: Literal['workflow'] = 'workflow', name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', steps: Annotated[tuple[Annotated[psych_runtime.core.spec.AgentStep | psych_runtime.core.spec.ToolStep | psych_runtime.core.spec.WorkflowStepRef, FieldInfo(annotation=NoneType, required=True, discriminator='kind')], ...], MinLen(min_length=1)], tools: tuple[typing.Annotated[psych_runtime.core.spec.CodeTool | psych_runtime.core.spec.HttpTool, FieldInfo(annotation=NoneType, required=True, discriminator='kind')], ...] = (), mcp_servers: tuple[psych_runtime.core.spec.McpServer, ...] = (), limits: psych_runtime.core.spec.Limits = <factory>, suspension: psych_runtime.core.spec.SuspensionPolicy = <factory>)

A Step that sequences other Steps deterministically (DESIGN.md §5).

A workflow step may be an agent, and an agent's tool may be a workflow, so recursion falls out and there is one durability implementation rather than two.

FieldTypeNotes
kindLiteral
namestr
descriptionstr
stepstuple
toolstuple
mcp_serverstuple
limitsLimits
suspensionSuspensionPolicy

AgentStep

model

class AgentStep(*, kind: Literal['agent'] = 'agent', name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], spec: psych_runtime.core.spec.AgentSpec)

A workflow step that runs an agent.

FieldTypeNotes
kindLiteral
namestr
specAgentSpec

ToolStep

model

class ToolStep(*, kind: Literal['tool'] = 'tool', name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], tool: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], arguments: dict[str, typing.Any] = <factory>)

A workflow step that calls one tool with fixed arguments.

FieldTypeNotes
kindLiteral
namestr
toolstr
argumentsdict

WorkflowStepRef

model

class WorkflowStepRef(*, kind: Literal['workflow'] = 'workflow', name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], spec: psych_runtime.core.spec.WorkflowSpec)

A workflow step that runs a nested workflow.

FieldTypeNotes
kindLiteral
namestr
specWorkflowSpec

ModelRef

model

class ModelRef(*, model: Annotated[str, MinLen(min_length=1), MaxLen(max_length=256)], temperature: Annotated[float | None, Ge(ge=0), Le(le=2)] = None, top_p: Annotated[float | None, Gt(gt=0), Le(le=1)] = None, max_output_tokens: Annotated[int | None, Gt(gt=0)] = None, reasoning_effort: Optional[Literal['low', 'medium', 'high']] = None, fallbacks: tuple[str, ...] = ())

Which model, and how it is called.

fallbacks is failover for a provider outage, not routing. DESIGN.md §19 puts automatic model routing out of scope for v1 and says no partial implementation should be added, so this list is tried in order on transient failure and is never chosen between on cost or capability.

FieldTypeNotes
modelstr
temperature`floatNone`
top_p`floatNone`
max_output_tokens`intNone`
reasoning_effortOptional
fallbackstuple

Limits

model

class Limits(*, max_steps: Annotated[int, Gt(gt=0), Le(le=10000)] = 48, max_turns: Annotated[int, Gt(gt=0), Le(le=1000)] = 32, max_tool_calls_per_turn: Annotated[int, Gt(gt=0), Le(le=256)] = 16, deadline_seconds: Annotated[float, Gt(gt=0), Le(le=86400)] = 900.0, transient_retry_budget: Annotated[int, Ge(ge=0), Le(le=100)] = 8, max_delegation_depth: Annotated[int, Ge(ge=0), Le(le=16)] = 3, max_fanout_per_turn: Annotated[int, Gt(gt=0), Le(le=64)] = 4, failure_streak_threshold: Annotated[int, Gt(gt=0), Le(le=100)] = 3, failure_streak_hard_stop: Annotated[int, Gt(gt=0), Le(le=200)] = 6, repeat_call_threshold: Annotated[int, Gt(gt=0), Le(le=100)] = 3, repeat_call_hard_stop: Annotated[int, Gt(gt=0), Le(le=200)] = 6, stream_idle_seconds: Annotated[float, Ge(ge=0), Le(le=3600)] = 300.0, large_result_bytes: Annotated[int, Gt(gt=0)] = 32768, max_history_records: Annotated[int, Ge(ge=0), Le(le=20000)] = 300)

Budgets. Every one of these has a default because every one of these prevents a cost incident, and an unset limit is an unbounded bill.

FieldTypeNotes
max_stepsint
max_turnsint
max_tool_calls_per_turnint
deadline_secondsfloat
transient_retry_budgetint
max_delegation_depthint
max_fanout_per_turnint
failure_streak_thresholdint
failure_streak_hard_stopint
repeat_call_thresholdint
repeat_call_hard_stopint
stream_idle_secondsfloat
large_result_bytesint
max_history_recordsint

SuspensionPolicy

model

class SuspensionPolicy(*, approval_expires_seconds: Annotated[float, Gt(gt=0)] = 86400.0, question_expires_seconds: Annotated[float, Gt(gt=0)] = 86400.0, external_expires_seconds: Annotated[float, Gt(gt=0)] = 604800.0, children_expires_seconds: Annotated[float, Gt(gt=0)] = 3600.0, may_ask_questions: bool = False)

How long a suspended Run waits before it is given up on.

DESIGN.md §11: suspensions expire, and a Run suspended past its expiry is settled as abandoned rather than waiting forever on a user who left.

FieldTypeNotes
approval_expires_secondsfloat
question_expires_secondsfloat
external_expires_secondsfloat
children_expires_secondsfloat
may_ask_questionsbool

CompactionPolicy

model

class CompactionPolicy(*, trigger_tokens: Annotated[int, Gt(gt=0)], keep_recent_turns: Annotated[int, Gt(gt=0), Le(le=100)] = 3, model: str | None = None, max_summary_tokens: Annotated[int, Gt(gt=0), Le(le=32000)] = 2048, summary_instructions: Annotated[str | None, MaxLen(max_length=4000)] = None)

When this agent replaces its older conversation with a summary.

Present means compaction is on for this agent; AgentSpec.compaction is None by default, which means a conversation grows until the provider refuses it. On the Spec rather than on the Runtime because an agent that summarises its own history is a different agent: what the model is shown on a long Run is not the same conversation, so the difference belongs in the Version hash (DESIGN.md §4) rather than in a flag a Runtime could change under a published Version.

The compaction itself is a write (psych_runtime.runtime.compaction); what it produces is a CompactionApplied Record, and the replaced records stay in the log. Compaction changes what the model sees next, never what happened.

FieldTypeNotes
trigger_tokensint
keep_recent_turnsint
model`strNone`
max_summary_tokensint
summary_instructions`strNone`

SpawnEnvelope

model

class SpawnEnvelope(*, tools: tuple[str, ...] = (), models: tuple[str, ...] = (), max_depth: Annotated[int, Ge(ge=1), Le(le=16)] = 2, max_alive: Annotated[int, Ge(ge=1), Le(le=32)] = 3, may_message: bool = True)

What a parent may compose at run time, rather than what it may call.

DESIGN.md §17 describes a roster: a parent lists its subagents, each subagent's Spec is embedded in the parent's, and one Version hash pins the whole tree. That is load-bearing -- psych_runtime.runtime.execute reloads the pinned Version at the top of every Attempt including a reclaiming Worker's, so a tree that could change between a crash and a reclaim would resume as a different tree -- and it stays exactly as it was.

A dynamically composed child is the other case: its instructions are written by the model at run time and so are not in the parent's Version at all. What pins them is the child's own Run. This model is the permission that makes that legal, and it is deliberately an envelope rather than a roster: it says whether this agent may compose children, out of which of its own tools, on which models, how deep and how many at once. Being on the Spec, it joins the Version hash like every other permission, so "this agent was allowed to write its own subagents" is pinned with everything else about it (DESIGN.md §4).

The child Spec composed inside this envelope is published as a Version like any other and recorded by hash in the spawn record, so crash recovery is unchanged: the child Run pins a hash, and replaying it re-reads that Spec rather than re-composing one from a prompt the model would write differently the second time.

Think of a SubagentRef as a pre-composed type and this as writing the brief fresh. Both paths exist; neither replaces the other.

FieldTypeNotes
toolstuple
modelstuple
max_depthint
max_aliveint
may_messagebool

SubagentRef

model

class SubagentRef(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MinLen(min_length=20), MaxLen(max_length=2048)], spec: psych_runtime.core.spec.AgentSpec)

A nested agent this Spec may delegate to.

The subagent's Spec is embedded rather than referenced by hash so that a Version is self-contained: one hash pins the whole delegation tree, and a trace read later cannot find a dangling child reference.

FieldTypeNotes
namestr
descriptionstr
specAgentSpec

Skill

model

class Skill(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MinLen(min_length=1), MaxLen(max_length=1024)], body: Annotated[str, MinLen(min_length=1)])

An instruction pack loaded on demand.

DESIGN.md §16: descriptions of every available skill sit in the system prompt; bodies load only when the model calls load_skill. Skills are part of the Spec, so they version and pin with it.

FieldTypeNotes
namestr
descriptionstr
bodystr

CodeTool

model

class CodeTool(*, kind: Literal['code'] = 'code', name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], interruptible: bool = True)

A Python function the consumer registered at boot, referenced by name.

The Spec carries the name and nothing else. The schema is derived from the registered function's type hints at registration time, so it lives in the registry rather than here; duplicating it would let the two drift.

FieldTypeNotes
kindLiteral
namestr
interruptiblebool

HttpTool

model

class HttpTool(*, kind: Literal['http'] = 'http', name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MinLen(min_length=1), MaxLen(max_length=4096)], url: Annotated[str, MinLen(min_length=1), MaxLen(max_length=2048)], method: Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] = 'POST', input_schema: dict[str, typing.Any] = <factory>, headers: dict[str, str] = <factory>, credential: Annotated[str | None, MaxLen(max_length=256)] = None, timeout_seconds: Annotated[float, Gt(gt=0), Le(le=600)] = 30.0, interruptible: bool = True)

A tool that is entirely data, so an end user can create one at runtime.

FieldTypeNotes
kindLiteral
namestr
descriptionstr
urlstr
methodLiteral
input_schemadict
headersdict
credential`strNone`
timeout_secondsfloat
interruptiblebool

McpServer

model

class McpServer(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], url: Annotated[str, MinLen(min_length=1), MaxLen(max_length=2048)], transport: Literal['http', 'sse'] = 'http', credential: Annotated[str | None, MaxLen(max_length=256)] = None, oauth: psych_runtime.core.spec.McpOAuth | None = None, allow: tuple[str, ...] = (), optional: bool = False, preload: bool | None = None)

An MCP connection, as data, creatable at runtime.

The tools a server contributes are discovered from the server, not declared here. allow narrows what this Spec is granted out of whatever the server offers, which is the third link in the chain of DESIGN.md §10.5.

FieldTypeNotes
namestr
urlstr
transportLiteral
credential`strNone`
oauth`spec.McpOAuthNone`
allowtuple
optionalbool
preload`boolNone`

McpOAuth

model

class McpOAuth(*, grant: Literal['authorization_code', 'client_credentials'] = 'client_credentials', preregistered_client_id: Annotated[str | None, MaxLen(max_length=512)] = None, client_secret_credential: Annotated[str | None, MaxLen(max_length=256)] = None, cimd_url: Annotated[str | None, MaxLen(max_length=2048)] = None, allow_dynamic_registration: bool = True, application_type: Literal['native', 'web'] = 'native', client_name: Annotated[str, MaxLen(max_length=256)] = 'psych', redirect_uris: tuple[str, ...] = ())

OAuth 2.1 configuration for one MCP server: which grant this server needs and which client identity requests it, carried per server rather than per McpPool so a Spec naming two servers behind two different authorization servers -- or one needing authorization_code and another client_credentials -- never has to run two pools.

Why this is Spec-carried rather than runtime configuration keyed by

server name (the design question this ticket asked to settle first)

The alternative was runtime configuration alongside the SecretResolver, the same way a consumer wires up their store or sandbox: keep McpServer exactly as it was and let a wiring layer map server names to OAuth identities and grants outside the Spec. That keeps the Spec free of what looks like deployment detail, and it is a real, defensible position -- OAuth setup often is environment-specific plumbing.

It loses to Spec-carried for the same reason McpServer.url, McpServer.transport and McpServer.credential are already Spec fields rather than runtime lookups keyed by server name: which authorization server a tool set sits behind, and which grant it needs to reach it, is part of what the agent is, not a knob layered on top of an otherwise-generic agent. Publishing "the CRM agent" already means publishing an agent that talks to one specific CRM at one specific URL; the OAuth client identity that CRM's authorization server expects is the same kind of fact, not a different kind. A client_id under OAuth 2.1 reinforces this: it is a public identifier of which application is calling (RFC 6749 draws the client-id/client-secret line at exactly public/confidential), the same register as a URL, not a secret -- so pinning preregistered_client_id in the Spec puts it exactly where McpServer.url already lives, and it travels with the agent into review and into the exported Version the same way. Deploying the same agent against a differently-registered OAuth app is, in the same sense a different server URL already is, a different Spec, not the same Spec with different config bolted on beside it -- and DESIGN.md §4 already commits to a Spec being the full, self-contained description of what an agent connects to.

What must never follow the client_id into the Spec, either way this decision went, is the one genuinely confidential half. A pre-registered confidential client's secret does not appear here as a value: client_secret_credential is a name, resolved through the same SecretResolver port McpServer.credential already resolves through, for the identical reason HttpTool refuses a literal Authorization header. Two Specs asking for the same client_secret_credential name may resolve to different values under different Scopes exactly as McpServer.credential already can, which is what lets one Spec, deployed for many tenants, use one shared application registration while each tenant's actual secret (if a per-tenant registration is what a consumer's authorization server requires) still resolves independently.

A consequence worth being honest about: the Version hash moves

psych_runtime.core.version.canonical_bytes serialises every field of every model, including one newly added with a default, so adding this field to McpServer changes the canonical bytes -- and therefore the hash -- of any Spec that already declares one or more mcp_servers. That is true of adding any field here, not particular to OAuth or to this decision: there is no way to extend McpServer without moving the hash of a Spec that uses it, short of excluding unset fields from the hash, which psych_runtime.core.version deliberately does not do (its docstring: "explicit null" and "not provided" must hash the same, which excluding-when-None would break in the other direction). A Spec with an empty mcp_servers tuple is unaffected -- there is no McpServer object for the new field to appear on. See tests/unit/test_version.py::TestMcpOAuthHashImpact for both halves verified against running code rather than assumed.

FieldTypeNotes
grantLiteral
preregistered_client_id`strNone`
client_secret_credential`strNone`
cimd_url`strNone`
allow_dynamic_registrationbool
application_typeLiteral
client_namestr
redirect_uristuple

A2APeer

model

class A2APeer(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], url: Annotated[str, MinLen(min_length=1), MaxLen(max_length=2048)], credential: Annotated[str | None, MaxLen(max_length=256)] = None, scheme: Annotated[str, MaxLen(max_length=64)] = 'Bearer', tenant: Annotated[str | None, MaxLen(max_length=256)] = None, allow: tuple[str, ...] = (), optional: bool = False, extensions: tuple[str, ...] = ())

Another agent, reachable over A2A, that this agent may delegate to.

The sibling of McpServer and shaped like it on purpose: a URL, a credential name, an allow list, and an optional flag. Everything a peer can actually do is discovered from its Agent Card at run time (psych_runtime.tools.a2a), never declared here, for the same reason an MCP server's tools are not declared here: what a remote system offers is a fact about that system, and a Spec that copied it would be advertising an ability that may have been withdrawn (DESIGN.md §10.7).

allow narrows the peer's skills the same way McpServer.allow narrows a server's tools, through the one narrowing function every plane of DESIGN.md §10.5 goes through. A peer with no allow list grants every skill its card declares.

Adding this field changes the Version hash of every Spec, because psych_runtime.core.version hashes the validated model with every field present. That is the same trade-off McpOAuth documented when it was added, and the same answer: the alternative is keeping peers in runtime configuration keyed by name, where two deployments could run "the same" published agent against different peers and the Version could not tell them apart. Which agents this agent may call is part of what this agent is.

FieldTypeNotes
namestr
urlstr
credential`strNone`
schemestr
tenant`strNone`
allowtuple
optionalbool
extensionstuple

Version

model

class Version(*, hash: psych_runtime.core.ids.VersionHash, spec: psych_runtime.core.spec.AgentSpec | psych_runtime.core.spec.WorkflowSpec, published_at: datetime.datetime)

An immutable publication of a Spec.

Attributes: hash: the content hash. The identity of this Version. spec: the Spec exactly as validated. published_at: when this Version was first stored. Outside the hashed body, because a timestamp inside it would make every republish produce a new Version.

FieldTypeNotes
hashVersionHash
spec`spec.AgentSpecspec.WorkflowSpec`
published_atdatetime

ValidationContext

class

class ValidationContext(registered_tools: 'Iterable[str]' = (), known_models: 'Iterable[str]' = (), reachable_mcp_servers: 'Iterable[str]' = ()) -> 'None'

What the world looks like at publish time.

Attributes: registered_tools: names of code tools the consumer registered at boot. An HTTP tool carries its own definition and needs no registration; a code tool is a reference to a function that must already exist. known_models: model ids the ModelClient will accept. Empty means "do not check", which is the right default for a consumer pointing at a proxy that can reach models Psych has never heard of. reachable_mcp_servers: server names that answered at publish. Empty means "do not check", because reachability is a network fact and a consumer validating a Spec offline should still get every other error.

AgentBuilder

class

class AgentBuilder(name: 'str', *, registry: 'ToolRegistry | None' = None) -> 'None'

Fluent construction of an AgentSpec.

spec = (
    AgentBuilder("support")
    .instructions("Help the customer with their order.")
    .model("gpt-4o", temperature=0.2, fallbacks=["gpt-4o-mini"])
    .tool(lookup_order)
    .http_tool(
        "issue_refund",
        description="Issue a refund for an order.",
        url="https://api.example.com/orders/\{order_id}/refund",
        method="POST",
        credential="payments-api-key",
    )
    .skill("refund-policy", "When a refund is allowed", body="...")
    .limits(max_steps=32)
    .build()
)

Every method returns self, so a Spec reads as the sequence of grants that built it. Nothing here is stateful beyond this one builder and the ToolRegistry it owns (or was handed): calling .build() any number of times replays the same fields into a fresh, equally valid AgentSpec.

WorkflowBuilder

class

class WorkflowBuilder(name: 'str', *, registry: 'ToolRegistry | None' = None) -> 'None'

Fluent construction of a WorkflowSpec.

spec = (
    WorkflowBuilder("onboard-customer")
    .tool(create_account)
    .tool_step("create", "create_account", \{"plan": "starter"})
    .agent_step("welcome", AgentBuilder("greeter").model("gpt-4o-mini"))
    .build()
)

A step's name is what psych_runtime.core.validation and a report address it by, so it must be unique within one workflow; the Spec model enforces that at validation, not this builder.

On this page