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, code_execution: psych_runtime.core.spec.CodeExecution | 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` |
code_execution | `spec.CodeExecution | 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 | psych_runtime.core.spec.ParallelStep | psych_runtime.core.spec.BranchStep | psych_runtime.core.spec.ForEachStep | psych_runtime.core.spec.LoopStep | psych_runtime.core.spec.MapStep | psych_runtime.core.spec.SetStateStep | psych_runtime.core.spec.SleepStep | psych_runtime.core.spec.WaitStep | psych_runtime.core.spec.HumanStep, 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>, input_schema: dict[str, typing.Any] | None = None, initial_state: dict[str, typing.Any] = <factory>, output: dict[str, typing.Annotated[psych_runtime.core.spec.ValuePath | psych_runtime.core.spec.LiteralValue, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]] | None = None, retry: psych_runtime.core.spec.RetryPolicy = <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 | |
input_schema | `dict[str, Any] | None` |
initial_state | dict | |
output | `dict[str, spec.ValuePath | spec.LiteralValue] |
retry | RetryPolicy |
AgentStep
model
class AgentStep(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', when: psych_runtime.core.spec.Condition | None = None, retry: psych_runtime.core.spec.RetryPolicy | None = None, timeout_seconds: Annotated[float | None, Gt(gt=0), Le(le=86400)] = None, on_failure: Literal['fail', 'continue'] = 'fail', output_schema: dict[str, typing.Any] | None = None, ends_workflow: bool = False, kind: Literal['agent'] = 'agent', spec: psych_runtime.core.spec.AgentSpec, input: dict[str, typing.Annotated[psych_runtime.core.spec.ValuePath | psych_runtime.core.spec.LiteralValue, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]] = <factory>)A workflow step that runs an agent to completion inside this Run.
input maps the nested agent's Run input. Its message field, when
mapped, is the first user message the agent sees; every other field is
serialised for it. Unmapped, the agent receives the workflow's own input.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
when | `spec.Condition | None` |
retry | `spec.RetryPolicy | None` |
timeout_seconds | `float | None` |
on_failure | Literal | |
output_schema | `dict[str, Any] | None` |
ends_workflow | bool | |
kind | Literal | |
spec | AgentSpec | |
input | dict |
ToolStep
model
class ToolStep(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', when: psych_runtime.core.spec.Condition | None = None, retry: psych_runtime.core.spec.RetryPolicy | None = None, timeout_seconds: Annotated[float | None, Gt(gt=0), Le(le=86400)] = None, on_failure: Literal['fail', 'continue'] = 'fail', output_schema: dict[str, typing.Any] | None = None, ends_workflow: bool = False, kind: Literal['tool'] = 'tool', tool: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], arguments: dict[str, typing.Any] = <factory>, arguments_from: dict[str, typing.Annotated[psych_runtime.core.spec.ValuePath | psych_runtime.core.spec.LiteralValue, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]] = <factory>)A workflow step that calls one tool.
arguments are written down; arguments_from fills or overrides
argument names from values the workflow already has. Both are resolved
before the call and the resolved arguments are what the log records.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
when | `spec.Condition | None` |
retry | `spec.RetryPolicy | None` |
timeout_seconds | `float | None` |
on_failure | Literal | |
output_schema | `dict[str, Any] | None` |
ends_workflow | bool | |
kind | Literal | |
tool | str | |
arguments | dict | |
arguments_from | dict |
WorkflowStepRef
model
class WorkflowStepRef(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', when: psych_runtime.core.spec.Condition | None = None, retry: psych_runtime.core.spec.RetryPolicy | None = None, timeout_seconds: Annotated[float | None, Gt(gt=0), Le(le=86400)] = None, on_failure: Literal['fail', 'continue'] = 'fail', output_schema: dict[str, typing.Any] | None = None, ends_workflow: bool = False, kind: Literal['workflow'] = 'workflow', spec: psych_runtime.core.spec.WorkflowSpec, input: dict[str, typing.Annotated[psych_runtime.core.spec.ValuePath | psych_runtime.core.spec.LiteralValue, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]] = <factory>)A workflow step that runs a nested workflow, sharing this Run's log.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
when | `spec.Condition | None` |
retry | `spec.RetryPolicy | None` |
timeout_seconds | `float | None` |
on_failure | Literal | |
output_schema | `dict[str, Any] | None` |
ends_workflow | bool | |
kind | Literal | |
spec | WorkflowSpec | |
input | dict |
ParallelStep
model
class ParallelStep(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', when: psych_runtime.core.spec.Condition | None = None, retry: psych_runtime.core.spec.RetryPolicy | None = None, timeout_seconds: Annotated[float | None, Gt(gt=0), Le(le=86400)] = None, on_failure: Literal['fail', 'continue'] = 'fail', output_schema: dict[str, typing.Any] | None = None, ends_workflow: bool = False, kind: Literal['parallel'] = 'parallel', branches: Annotated[tuple[Annotated[psych_runtime.core.spec.AgentStep | psych_runtime.core.spec.ToolStep | psych_runtime.core.spec.WorkflowStepRef | psych_runtime.core.spec.ParallelStep | psych_runtime.core.spec.BranchStep | psych_runtime.core.spec.ForEachStep | psych_runtime.core.spec.LoopStep | psych_runtime.core.spec.MapStep | psych_runtime.core.spec.SetStateStep | psych_runtime.core.spec.SleepStep | psych_runtime.core.spec.WaitStep | psych_runtime.core.spec.HumanStep, FieldInfo(annotation=NoneType, required=True, discriminator='kind')], ...], MinLen(min_length=1)], on_branch_failure: Literal['fail_fast', 'wait_all'] = 'fail_fast')Run every branch at once; the output is each branch's output by name.
fail_fast cancels the other branches at the first failure. wait_all
lets them finish and then reports the first failure, for branches whose
side effects should complete even when a sibling did not.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
when | `spec.Condition | None` |
retry | `spec.RetryPolicy | None` |
timeout_seconds | `float | None` |
on_failure | Literal | |
output_schema | `dict[str, Any] | None` |
ends_workflow | bool | |
kind | Literal | |
branches | tuple | |
on_branch_failure | Literal |
BranchStep
model
class BranchStep(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', when: psych_runtime.core.spec.Condition | None = None, retry: psych_runtime.core.spec.RetryPolicy | None = None, timeout_seconds: Annotated[float | None, Gt(gt=0), Le(le=86400)] = None, on_failure: Literal['fail', 'continue'] = 'fail', output_schema: dict[str, typing.Any] | None = None, ends_workflow: bool = False, kind: Literal['branch'] = 'branch', cases: Annotated[tuple[psych_runtime.core.spec.BranchCase, ...], MinLen(min_length=1)], otherwise: Optional[Annotated[psych_runtime.core.spec.AgentStep | psych_runtime.core.spec.ToolStep | psych_runtime.core.spec.WorkflowStepRef | psych_runtime.core.spec.ParallelStep | psych_runtime.core.spec.BranchStep | psych_runtime.core.spec.ForEachStep | psych_runtime.core.spec.LoopStep | psych_runtime.core.spec.MapStep | psych_runtime.core.spec.SetStateStep | psych_runtime.core.spec.SleepStep | psych_runtime.core.spec.WaitStep | psych_runtime.core.spec.HumanStep, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]] = None, mode: Literal['first', 'all'] = 'first')Choose which step runs by evaluating conditions.
first runs the first case whose condition holds, or otherwise;
all runs every case that holds, concurrently. The output carries
chosen (the case names that ran) and each ran case's output by name.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
when | `spec.Condition | None` |
retry | `spec.RetryPolicy | None` |
timeout_seconds | `float | None` |
on_failure | Literal | |
output_schema | `dict[str, Any] | None` |
ends_workflow | bool | |
kind | Literal | |
cases | tuple | |
otherwise | Union | |
mode | Literal |
BranchCase
model
class BranchCase(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], when: psych_runtime.core.spec.Condition, step: psych_runtime.core.spec.AgentStep | psych_runtime.core.spec.ToolStep | psych_runtime.core.spec.WorkflowStepRef | psych_runtime.core.spec.ParallelStep | psych_runtime.core.spec.BranchStep | psych_runtime.core.spec.ForEachStep | psych_runtime.core.spec.LoopStep | psych_runtime.core.spec.MapStep | psych_runtime.core.spec.SetStateStep | psych_runtime.core.spec.SleepStep | psych_runtime.core.spec.WaitStep | psych_runtime.core.spec.HumanStep)One arm of a BranchStep.
| Field | Type | Notes |
|---|---|---|
name | str | |
when | Condition | |
step | `spec.AgentStep | spec.ToolStep |
ForEachStep
model
class ForEachStep(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', when: psych_runtime.core.spec.Condition | None = None, retry: psych_runtime.core.spec.RetryPolicy | None = None, timeout_seconds: Annotated[float | None, Gt(gt=0), Le(le=86400)] = None, on_failure: Literal['fail', 'continue'] = 'fail', output_schema: dict[str, typing.Any] | None = None, ends_workflow: bool = False, kind: Literal['foreach'] = 'foreach', items: psych_runtime.core.spec.ValuePath, body: psych_runtime.core.spec.AgentStep | psych_runtime.core.spec.ToolStep | psych_runtime.core.spec.WorkflowStepRef | psych_runtime.core.spec.ParallelStep | psych_runtime.core.spec.BranchStep | psych_runtime.core.spec.ForEachStep | psych_runtime.core.spec.LoopStep | psych_runtime.core.spec.MapStep | psych_runtime.core.spec.SetStateStep | psych_runtime.core.spec.SleepStep | psych_runtime.core.spec.WaitStep | psych_runtime.core.spec.HumanStep, concurrency: Annotated[int, Ge(ge=1), Le(le=64)] = 1, on_item_failure: Literal['fail_fast', 'wait_all'] = 'fail_fast')Run body once per element of the list at items.
Each iteration sees its element as item and its position as index.
Iterations run concurrency at a time and each is memoised on its own,
so a crash mid-list resumes with the finished elements kept. The output is
\{"items": [output per element, in order]}.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
when | `spec.Condition | None` |
retry | `spec.RetryPolicy | None` |
timeout_seconds | `float | None` |
on_failure | Literal | |
output_schema | `dict[str, Any] | None` |
ends_workflow | bool | |
kind | Literal | |
items | ValuePath | |
body | `spec.AgentStep | spec.ToolStep |
concurrency | int | |
on_item_failure | Literal |
LoopStep
model
class LoopStep(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', when: psych_runtime.core.spec.Condition | None = None, retry: psych_runtime.core.spec.RetryPolicy | None = None, timeout_seconds: Annotated[float | None, Gt(gt=0), Le(le=86400)] = None, on_failure: Literal['fail', 'continue'] = 'fail', output_schema: dict[str, typing.Any] | None = None, ends_workflow: bool = False, kind: Literal['loop'] = 'loop', body: psych_runtime.core.spec.AgentStep | psych_runtime.core.spec.ToolStep | psych_runtime.core.spec.WorkflowStepRef | psych_runtime.core.spec.ParallelStep | psych_runtime.core.spec.BranchStep | psych_runtime.core.spec.ForEachStep | psych_runtime.core.spec.LoopStep | psych_runtime.core.spec.MapStep | psych_runtime.core.spec.SetStateStep | psych_runtime.core.spec.SleepStep | psych_runtime.core.spec.WaitStep | psych_runtime.core.spec.HumanStep, until: psych_runtime.core.spec.Condition | None = None, while_: psych_runtime.core.spec.Condition | None = None, max_iterations: Annotated[int, Ge(ge=1), Le(le=10000)] = 100)Run body repeatedly: at least once, then while while_ holds or
until until holds, whichever is set.
Each iteration sees its number as iteration (from 1) and the previous
iteration's output as steps.<body>.output. max_iterations bounds
the loop; reaching it is a failure of kind loop_exhausted. The output
is \{"iterations": n, "last": <last body output>}.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
when | `spec.Condition | None` |
retry | `spec.RetryPolicy | None` |
timeout_seconds | `float | None` |
on_failure | Literal | |
output_schema | `dict[str, Any] | None` |
ends_workflow | bool | |
kind | Literal | |
body | `spec.AgentStep | spec.ToolStep |
until | `spec.Condition | None` |
while_ | `spec.Condition | None` |
max_iterations | int |
MapStep
model
class MapStep(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', when: psych_runtime.core.spec.Condition | None = None, retry: psych_runtime.core.spec.RetryPolicy | None = None, timeout_seconds: Annotated[float | None, Gt(gt=0), Le(le=86400)] = None, on_failure: Literal['fail', 'continue'] = 'fail', output_schema: dict[str, typing.Any] | None = None, ends_workflow: bool = False, kind: Literal['map'] = 'map', output: Annotated[dict[str, Annotated[psych_runtime.core.spec.ValuePath | psych_runtime.core.spec.LiteralValue, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]], MinLen(min_length=1)])Shape data between steps, with no side effects.
The output is output with every reference resolved. This is the one
place a workflow rearranges values, and it is a step like any other so
the shaping is in the log rather than hidden inside another step's input.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
when | `spec.Condition | None` |
retry | `spec.RetryPolicy | None` |
timeout_seconds | `float | None` |
on_failure | Literal | |
output_schema | `dict[str, Any] | None` |
ends_workflow | bool | |
kind | Literal | |
output | dict |
SetStateStep
model
class SetStateStep(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', when: psych_runtime.core.spec.Condition | None = None, retry: psych_runtime.core.spec.RetryPolicy | None = None, timeout_seconds: Annotated[float | None, Gt(gt=0), Le(le=86400)] = None, on_failure: Literal['fail', 'continue'] = 'fail', output_schema: dict[str, typing.Any] | None = None, ends_workflow: bool = False, kind: Literal['set_state'] = 'set_state', values: Annotated[dict[str, Annotated[psych_runtime.core.spec.ValuePath | psych_runtime.core.spec.LiteralValue, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]], MinLen(min_length=1)])Write values into the workflow's state.
State is the small, named set of values a workflow carries across steps
and loop iterations, read as state.<name>. It is derived from the log
by folding every completed set_state step in order, so it survives a
Worker crash without being stored anywhere else.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
when | `spec.Condition | None` |
retry | `spec.RetryPolicy | None` |
timeout_seconds | `float | None` |
on_failure | Literal | |
output_schema | `dict[str, Any] | None` |
ends_workflow | bool | |
kind | Literal | |
values | dict |
SleepStep
model
class SleepStep(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', when: psych_runtime.core.spec.Condition | None = None, retry: psych_runtime.core.spec.RetryPolicy | None = None, timeout_seconds: Annotated[float | None, Gt(gt=0), Le(le=86400)] = None, on_failure: Literal['fail', 'continue'] = 'fail', output_schema: dict[str, typing.Any] | None = None, ends_workflow: bool = False, kind: Literal['sleep'] = 'sleep', seconds: Annotated[float | None, Gt(gt=0), Le(le=2592000)] = None, until: psych_runtime.core.spec.ValuePath | None = None)Wait, holding no Worker: the Run releases its lease and is claimable
again at the wake time. seconds or until (a path to an ISO 8601
timestamp) sets it; exactly one is required.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
when | `spec.Condition | None` |
retry | `spec.RetryPolicy | None` |
timeout_seconds | `float | None` |
on_failure | Literal | |
output_schema | `dict[str, Any] | None` |
ends_workflow | bool | |
kind | Literal | |
seconds | `float | None` |
until | `spec.ValuePath | None` |
WaitStep
model
class WaitStep(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', when: psych_runtime.core.spec.Condition | None = None, retry: psych_runtime.core.spec.RetryPolicy | None = None, timeout_seconds: Annotated[float | None, Gt(gt=0), Le(le=2592000)] = None, on_failure: Literal['fail', 'continue'] = 'fail', output_schema: dict[str, typing.Any] | None = None, ends_workflow: bool = False, kind: Literal['wait'] = 'wait', event: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], payload_schema: dict[str, typing.Any] = <factory>)Suspend until something outside the Run delivers a payload.
The Run suspends with reason external naming event and, when set,
payload_schema; psych_runtime.resume(payload=...) completes the step
with the payload as its output. timeout_seconds bounds the wait; the
suspension expires past it and the Run is abandoned, exactly as an
unanswered approval is.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
when | `spec.Condition | None` |
retry | `spec.RetryPolicy | None` |
timeout_seconds | `float | None` |
on_failure | Literal | |
output_schema | `dict[str, Any] | None` |
ends_workflow | bool | |
kind | Literal | |
event | str | |
payload_schema | dict |
HumanStep
model
class HumanStep(*, name: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')], description: Annotated[str, MaxLen(max_length=4096)] = '', when: psych_runtime.core.spec.Condition | None = None, retry: psych_runtime.core.spec.RetryPolicy | None = None, timeout_seconds: Annotated[float | None, Gt(gt=0), Le(le=86400)] = None, on_failure: Literal['fail', 'continue'] = 'fail', output_schema: dict[str, typing.Any] | None = None, ends_workflow: bool = False, kind: Literal['human'] = 'human', prompt: Annotated[str, MinLen(min_length=1), MaxLen(max_length=8192)], questions: tuple[psych_runtime.core.questions.AskedQuestion, ...] = (), expires_seconds: Annotated[float | None, Gt(gt=0), Le(le=2592000)] = None)Stop and ask a person, without a model in the loop.
Suspends with reason question carrying prompt and questions;
the answer delivered by psych_runtime.resume(payload=...) is the step's
output. expires_seconds falls back to the workflow's
suspension.question_expires_seconds.
| Field | Type | Notes |
|---|---|---|
name | str | |
description | str | |
when | `spec.Condition | None` |
retry | `spec.RetryPolicy | None` |
timeout_seconds | `float | None` |
on_failure | Literal | |
output_schema | `dict[str, Any] | None` |
ends_workflow | bool | |
kind | Literal | |
prompt | str | |
questions | tuple | |
expires_seconds | `float | None` |
Condition
model
class Condition(*, path: Annotated[str | None, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.\\-\\[\\]]{0,511}$')] = None, op: Literal['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'contains', 'exists', 'truthy', 'matches'] = 'truthy', value: Any = None, all_of: tuple[psych_runtime.core.spec.Condition, ...] = (), any_of: tuple[psych_runtime.core.spec.Condition, ...] = (), negate: bool = False)A predicate over values the workflow already has.
Exactly one of path, all_of or any_of is set. A leaf compares
the value at path against value with op; a compound combines
its children. negate inverts the result. Declarative on purpose: the
engine evaluates it, records what it decided, and a reader can evaluate it
again from the log by hand.
| Field | Type | Notes |
|---|---|---|
path | `str | None` |
op | Literal | |
value | Any | |
all_of | tuple | |
any_of | tuple | |
negate | bool |
ValuePath
model
class ValuePath(*, kind: Literal['path'] = 'path', path: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.\\-\\[\\]]{0,511}$')])A reference to a value the workflow already has.
Resolved by the engine when the step starts, against the Run's input, the
outputs of completed steps, the workflow state, and the loop position:
input.customer_id, steps.fetch.output.rows[0].id, state.total,
item.sku, index, iteration. The resolved value is what lands in
the step's step_started record, so the log shows what the step was
actually given rather than a reference to go and look up.
| Field | Type | Notes |
|---|---|---|
kind | Literal | |
path | str |
LiteralValue
model
class LiteralValue(*, kind: Literal['literal'] = 'literal', value: Any = None)A value written down in the Spec, for a mapping that mixes constants with references.
| Field | Type | Notes |
|---|---|---|
kind | Literal | |
value | Any |
RetryPolicy
model
class RetryPolicy(*, max_attempts: Annotated[int, Ge(ge=1), Le(le=100)] = 1, backoff_seconds: Annotated[float, Ge(ge=0), Le(le=86400)] = 0.0, multiplier: Annotated[float, Ge(ge=1.0), Le(le=10.0)] = 2.0, max_backoff_seconds: Annotated[float, Gt(gt=0), Le(le=86400)] = 3600.0, retry_on: tuple[str, ...] = ())How a failed step is retried before the failure counts.
max_attempts is the total including the first, so the default of one
means no retry. A backoff parks the Run between attempts by releasing its
lease rather than sleeping on a Worker, so a long backoff costs nothing
while it waits. retry_on narrows retries to the named failure kinds;
empty retries any failure.
| Field | Type | Notes |
|---|---|---|
max_attempts | int | |
backoff_seconds | float | |
multiplier | float | |
max_backoff_seconds | float | |
retry_on | tuple |
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` |
CodeExecution
model
class CodeExecution(*, enabled: bool = True, profile: Annotated[str, _PydanticGeneralMetadata(pattern='^[a-zA-Z_][a-zA-Z0-9_.-]{0,127}$')] = 'default', isolation: psych_runtime.core.code_execution.IsolationLevel = <IsolationLevel.ISOLATED: 'isolated'>, network: psych_runtime.core.code_execution.NetworkAccess = <NetworkAccess.DENIED: 'denied'>, limits: psych_runtime.core.spec.CodeExecutionLimits = <factory>, bindings: tuple[str, ...] | None = None, workspace: psych_runtime.core.code_execution.WorkspacePolicy = <WorkspacePolicy.EPHEMERAL: 'ephemeral'>, output: psych_runtime.core.spec.OutputPolicy = <factory>, artifacts: psych_runtime.core.spec.ArtifactPolicy = <factory>, language: Literal['python'] = 'python')Whether this agent may run programs, and under what terms.
Absent from an AgentSpec means off: the agent is never shown
run_code, whatever the deployment has wired. Present, it is part of the
Version hash like every other field here, because an agent that can write
and run a program is a different agent from one that cannot, and its every
turn is offered a different tool.
Everything in here is a request expressed in serialisable names. The
Spec never holds the sandbox, its credentials, a client or a process: a
deployment maps profile to an implementation through
psych_runtime.Runtime (DESIGN.md §4, a Spec holds names and never a
callable), and the effective terms of an execution are the intersection of
what that implementation can do, what the tenant's Policy allows, and what
is asked for here (psych_runtime.sandbox.profiles).
Attributes:
enabled: whether run_code is offered at all. Kept as a field rather
than modelled by the presence of this object so a configuration
can be switched off and back on without being retyped.
profile: the logical sandbox this agent runs programs in. A name the
deployment resolves; "default" is what Runtime(sandbox=...)
registers.
isolation: the minimum isolation this agent accepts. ISOLATED by
default. A profile that cannot provide it refuses the execution
rather than running it weaker; PROCESS is a deliberate choice
for trusted code and is never picked on the agent's behalf.
network: whether the program may open its own sockets. Denied by
default, and grantable only where the profile permits it.
limits: requested caps. Narrowed against the profile's hard limits.
bindings: which of this agent's tools a program may call as host
functions.
None -- the default -- means every tool this agent can
currently call, after every narrowing plane: registered code
tools, HTTP tools, and the tools each granted MCP server and A2A
peer offers this tenant right now. Not a copy of anything: the set
is recomputed at each turn boundary from that turn's live
catalogue, so a tool a tenant policy withdraws stops being callable
from a program in the same turn it stops being callable directly.
A tuple narrows to a subset and can never widen one. Static names
are validated against tools at publish; a server-qualified name
(github__search_issues) is validated only as far as offline
validation honestly can -- that the server is one this agent
connects to -- because whether that server still offers that tool
is not a fact about this Spec. A named binding that does not
resolve at run time is reported to the model as unavailable and is
never quietly replaced with another tool.
What a program may never call, whatever this says: anything that
would suspend the Run, and the Psych built-ins listed in
psych_runtime.core.code_execution.EXCLUDED_BUILTINS.
workspace: what happens to the working directory between executions.
output: how much output the model sees and what happens to the rest.
artifacts: whether files the program writes are collected.
language: what the program is written in. Python only today.
| Field | Type | Notes |
|---|---|---|
enabled | bool | |
profile | str | |
isolation | IsolationLevel | |
network | NetworkAccess | |
limits | CodeExecutionLimits | |
bindings | `tuple[str, ...] | None` |
workspace | WorkspacePolicy | |
output | OutputPolicy | |
artifacts | ArtifactPolicy | |
language | Literal |
CodeExecutionLimits
model
class CodeExecutionLimits(*, cpu_seconds: Annotated[float | None, Gt(gt=0), Le(le=3600)] = None, wall_seconds: Annotated[float | None, Gt(gt=0), Le(le=3600)] = None, memory_bytes: Annotated[int | None, Gt(gt=0)] = None, file_size_bytes: Annotated[int | None, Gt(gt=0)] = None, process_count: Annotated[int | None, Gt(gt=0), Le(le=4096)] = None)The resource caps an agent asks for, each optional.
Requests, not grants. The deployment's sandbox profile carries hard
limits, and the effective cap for an execution is the smaller of the two on
every dimension (psych_runtime.sandbox.profiles.narrow_limits): an agent
can ask for less than its deployment allows and never for more. None
means "whatever the profile's default is", which is what almost every
agent wants.
Attributes: cpu_seconds: CPU time actually consumed, not wall clock. wall_seconds: real time from spawn to teardown. memory_bytes: the memory the program may use. Enforced as address space by a process backend, as a cgroup by a container, and as a job-object commit limit on Windows; the name is the same because the intent is. file_size_bytes: the largest single file the program may write. process_count: how many processes (threads count on Linux) it may hold.
| Field | Type | Notes |
|---|---|---|
cpu_seconds | `float | None` |
wall_seconds | `float | None` |
memory_bytes | `int | None` |
file_size_bytes | `int | None` |
process_count | `int | None` |
OutputPolicy
model
class OutputPolicy(*, preview_bytes: Annotated[int, Ge(ge=256), Le(le=65536)] = 4000, max_bytes: Annotated[int, Ge(ge=4096), Le(le=268435456)] = 8388608, preserve: psych_runtime.core.code_execution.OutputPreservation = <OutputPreservation.WHEN_AVAILABLE: 'when_available'>)How much of a program's output reaches the model, and what happens to the rest.
Two budgets and one decision, because they answer different questions:
preview_bytes is what the model sees per stream and belongs to the
agent's context economy; max_bytes is how much a backend will
capture per stream before it stops reading and reports the stream
truncated, which bounds this process's memory; preserve is whether the
captured bytes beyond the preview are kept durably in the Runtime's
BlobStore so read_tool_output can page through them later.
Attributes: preview_bytes: per stream (stdout, stderr, returned value), the most that is placed in the model's context. Deterministic head and tail. max_bytes: per stream, the most a backend captures. Beyond it the stream is truncated and the result says so. preserve: what must happen to captured output beyond the preview.
| Field | Type | Notes |
|---|---|---|
preview_bytes | int | |
max_bytes | int | |
preserve | OutputPreservation |
ArtifactPolicy
model
class ArtifactPolicy(*, collection: psych_runtime.core.code_execution.ArtifactCollection = <ArtifactCollection.COLLECT: 'collect'>, max_count: Annotated[int, Ge(ge=0), Le(le=256)] = 16, max_total_bytes: Annotated[int, Ge(ge=0), Le(le=268435456)] = 16777216)Whether, and how much, of what a program writes to its workspace is kept.
Files are returned by their path relative to the workspace and never by a host path (DESIGN.md §18: the program's view of the filesystem is not the host's, and a report must not leak where an execution happened to run). Symbolic links, junctions and other reparse points are never followed and never collected.
Attributes: collection: collect regular files under the workspace, or ignore them. max_count: the most files collected per execution. More are ignored and the result says how many. max_total_bytes: the most bytes collected across every file. A file that would cross the cap is truncated and marked so.
| Field | Type | Notes |
|---|---|---|
collection | ArtifactCollection | |
max_count | int | |
max_total_bytes | int |
IsolationLevel
enum
class IsolationLevel(*values)How strongly a program is separated from the host it runs on.
Ordered: ISOLATED is stronger than PROCESS. satisfies is the
one comparison the runtime makes, so the ordering lives here rather than
being re-derived at each call site.
| Member | Value |
|---|---|
ISOLATED | isolated |
PROCESS | process |
NetworkAccess
enum
class NetworkAccess(*values)Whether the program may open its own sockets.
| Member | Value |
|---|---|
DENIED | denied |
UNRESTRICTED | unrestricted |
WorkspacePolicy
enum
class WorkspacePolicy(*values)What happens to the working directory between executions.
| Member | Value |
|---|---|
EPHEMERAL | ephemeral |
OutputPreservation
enum
class OutputPreservation(*values)What must happen to output too large for the model to see whole.
| Member | Value |
|---|---|
WHEN_AVAILABLE | when_available |
REQUIRED | required |
NEVER | never |
ArtifactCollection
enum
class ArtifactCollection(*values)Whether files the program writes to its workspace are collected.
| Member | Value |
|---|---|
COLLECT | collect |
IGNORE | ignore |
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,63}$')], 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,63}$')], 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, issuer: Annotated[str | None, MaxLen(max_length=2048)] = 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` |
issuer | `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]' = (), sandbox_profiles: '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.
sandbox_profiles: the logical sandbox names the deployment resolves
(psych_runtime.Runtime's profile registry). Empty means "do not
check". Given, an agent whose code_execution.profile names
nothing here is refused at publish rather than at its first
run_code call.
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", arguments_from=\{"email": ref("input.email")})
.branch(
"tier",
("paid", when("input.plan", "ne", "free"), ToolStep(name="bill", tool="charge")),
otherwise=MapStep(name="free", output=\{"charged": lit(False)}),
)
.agent_step("welcome", AgentBuilder("greeter").model("gpt-4o-mini"))
.output(account=ref("steps.create.output.result"))
.build()
)Composite steps take their children as Spec models (ToolStep,
MapStep and the rest, all on psych_runtime), so nesting reads the
way the tree runs; ref, lit, when, all_of and any_of
build the references and conditions.
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.