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).
| Field | Type | Notes |
|---|---|---|
kind | Literal | |
name | str | |
description | str | |
instructions | str | |
model | ModelRef | |
tools | tuple | |
mcp_servers | tuple | |
a2a_peers | tuple | |
skills | tuple | |
subagents | tuple | |
spawn | `spec.SpawnEnvelope | None` |
compaction | `spec.CompactionPolicy | None` |
limits | Limits | |
suspension | SuspensionPolicy | |
tasks_enabled | bool | |
components_enabled | bool | |
answer_style | Optional |
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.
| Field | Type | Notes |
|---|---|---|
kind | Literal | |
name | str | |
description | str | |
steps | tuple | |
tools | tuple | |
mcp_servers | tuple | |
limits | Limits | |
suspension | SuspensionPolicy |
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.
| Field | Type | Notes |
|---|---|---|
kind | Literal | |
name | str | |
spec | AgentSpec |
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.
| Field | Type | Notes |
|---|---|---|
kind | Literal | |
name | str | |
tool | str | |
arguments | dict |
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.
| Field | Type | Notes |
|---|---|---|
kind | Literal | |
name | str | |
spec | WorkflowSpec |
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.
| Field | Type | Notes |
|---|---|---|
model | str | |
temperature | `float | None` |
top_p | `float | None` |
max_output_tokens | `int | None` |
reasoning_effort | Optional | |
fallbacks | tuple |
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.
| Field | Type | Notes |
|---|---|---|
max_steps | int | |
max_turns | int | |
max_tool_calls_per_turn | int | |
deadline_seconds | float | |
transient_retry_budget | int | |
max_delegation_depth | int | |
max_fanout_per_turn | int | |
failure_streak_threshold | int | |
failure_streak_hard_stop | int | |
repeat_call_threshold | int | |
repeat_call_hard_stop | int | |
stream_idle_seconds | float | |
large_result_bytes | int | |
max_history_records | int |
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.
| Field | Type | Notes |
|---|---|---|
approval_expires_seconds | float | |
question_expires_seconds | float | |
external_expires_seconds | float | |
children_expires_seconds | float | |
may_ask_questions | bool |
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.
| Field | Type | Notes |
|---|---|---|
trigger_tokens | int | |
keep_recent_turns | int | |
model | `str | None` |
max_summary_tokens | int | |
summary_instructions | `str | None` |
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.
| Field | Type | Notes |
|---|---|---|
tools | tuple | |
models | tuple | |
max_depth | int | |
max_alive | int | |
may_message | bool |
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.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
spec | AgentSpec |
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.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
body | str |
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.
| Field | Type | Notes |
|---|---|---|
kind | Literal | |
name | str | |
interruptible | bool |
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.
| Field | Type | Notes |
|---|---|---|
kind | Literal | |
name | str | |
description | str | |
url | str | |
method | Literal | |
input_schema | dict | |
headers | dict | |
credential | `str | None` |
timeout_seconds | float | |
interruptible | bool |
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.
| Field | Type | Notes |
|---|---|---|
name | str | |
url | str | |
transport | Literal | |
credential | `str | None` |
oauth | `spec.McpOAuth | None` |
allow | tuple | |
optional | bool | |
preload | `bool | None` |
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.
| Field | Type | Notes |
|---|---|---|
grant | Literal | |
preregistered_client_id | `str | None` |
client_secret_credential | `str | None` |
cimd_url | `str | None` |
allow_dynamic_registration | bool | |
application_type | Literal | |
client_name | str | |
redirect_uris | tuple |
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.
| Field | Type | Notes |
|---|---|---|
name | str | |
url | str | |
credential | `str | None` |
scheme | str | |
tenant | `str | None` |
allow | tuple | |
optional | bool | |
extensions | tuple |
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.
| Field | Type | Notes |
|---|---|---|
hash | VersionHash | |
spec | `spec.AgentSpec | spec.WorkflowSpec` |
published_at | datetime |
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.