Psych Runtime
Reference

Spec models

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

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

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

FieldTypeNotes
kindLiteral
namestr
descriptionstr
instructionsstr
modelModelRef
toolstuple
mcp_serverstuple
a2a_peerstuple
skillstuple
subagentstuple
spawn`spec.SpawnEnvelopeNone`
compaction`spec.CompactionPolicyNone`
code_execution`spec.CodeExecutionNone`
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 | 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.

FieldTypeNotes
kindLiteral
namestr
descriptionstr
stepstuple
toolstuple
mcp_serverstuple
limitsLimits
suspensionSuspensionPolicy
input_schema`dict[str, Any]None`
initial_statedict
output`dict[str, spec.ValuePathspec.LiteralValue]
retryRetryPolicy

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.

FieldTypeNotes
namestr
descriptionstr
when`spec.ConditionNone`
retry`spec.RetryPolicyNone`
timeout_seconds`floatNone`
on_failureLiteral
output_schema`dict[str, Any]None`
ends_workflowbool
kindLiteral
specAgentSpec
inputdict

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.

FieldTypeNotes
namestr
descriptionstr
when`spec.ConditionNone`
retry`spec.RetryPolicyNone`
timeout_seconds`floatNone`
on_failureLiteral
output_schema`dict[str, Any]None`
ends_workflowbool
kindLiteral
toolstr
argumentsdict
arguments_fromdict

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.

FieldTypeNotes
namestr
descriptionstr
when`spec.ConditionNone`
retry`spec.RetryPolicyNone`
timeout_seconds`floatNone`
on_failureLiteral
output_schema`dict[str, Any]None`
ends_workflowbool
kindLiteral
specWorkflowSpec
inputdict

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.

FieldTypeNotes
namestr
descriptionstr
when`spec.ConditionNone`
retry`spec.RetryPolicyNone`
timeout_seconds`floatNone`
on_failureLiteral
output_schema`dict[str, Any]None`
ends_workflowbool
kindLiteral
branchestuple
on_branch_failureLiteral

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.

FieldTypeNotes
namestr
descriptionstr
when`spec.ConditionNone`
retry`spec.RetryPolicyNone`
timeout_seconds`floatNone`
on_failureLiteral
output_schema`dict[str, Any]None`
ends_workflowbool
kindLiteral
casestuple
otherwiseUnion
modeLiteral

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.

FieldTypeNotes
namestr
whenCondition
step`spec.AgentStepspec.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]}.

FieldTypeNotes
namestr
descriptionstr
when`spec.ConditionNone`
retry`spec.RetryPolicyNone`
timeout_seconds`floatNone`
on_failureLiteral
output_schema`dict[str, Any]None`
ends_workflowbool
kindLiteral
itemsValuePath
body`spec.AgentStepspec.ToolStep
concurrencyint
on_item_failureLiteral

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.&lt;body>.output. max_iterations bounds the loop; reaching it is a failure of kind loop_exhausted. The output is \{"iterations": n, "last": &lt;last body output>}.

FieldTypeNotes
namestr
descriptionstr
when`spec.ConditionNone`
retry`spec.RetryPolicyNone`
timeout_seconds`floatNone`
on_failureLiteral
output_schema`dict[str, Any]None`
ends_workflowbool
kindLiteral
body`spec.AgentStepspec.ToolStep
until`spec.ConditionNone`
while_`spec.ConditionNone`
max_iterationsint

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.

FieldTypeNotes
namestr
descriptionstr
when`spec.ConditionNone`
retry`spec.RetryPolicyNone`
timeout_seconds`floatNone`
on_failureLiteral
output_schema`dict[str, Any]None`
ends_workflowbool
kindLiteral
outputdict

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.&lt;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.

FieldTypeNotes
namestr
descriptionstr
when`spec.ConditionNone`
retry`spec.RetryPolicyNone`
timeout_seconds`floatNone`
on_failureLiteral
output_schema`dict[str, Any]None`
ends_workflowbool
kindLiteral
valuesdict

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.

FieldTypeNotes
namestr
descriptionstr
when`spec.ConditionNone`
retry`spec.RetryPolicyNone`
timeout_seconds`floatNone`
on_failureLiteral
output_schema`dict[str, Any]None`
ends_workflowbool
kindLiteral
seconds`floatNone`
until`spec.ValuePathNone`

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.

FieldTypeNotes
namestr
descriptionstr
when`spec.ConditionNone`
retry`spec.RetryPolicyNone`
timeout_seconds`floatNone`
on_failureLiteral
output_schema`dict[str, Any]None`
ends_workflowbool
kindLiteral
eventstr
payload_schemadict

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.

FieldTypeNotes
namestr
descriptionstr
when`spec.ConditionNone`
retry`spec.RetryPolicyNone`
timeout_seconds`floatNone`
on_failureLiteral
output_schema`dict[str, Any]None`
ends_workflowbool
kindLiteral
promptstr
questionstuple
expires_seconds`floatNone`

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.

FieldTypeNotes
path`strNone`
opLiteral
valueAny
all_oftuple
any_oftuple
negatebool

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.

FieldTypeNotes
kindLiteral
pathstr

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.

FieldTypeNotes
kindLiteral
valueAny

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.

FieldTypeNotes
max_attemptsint
backoff_secondsfloat
multiplierfloat
max_backoff_secondsfloat
retry_ontuple

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`

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.

FieldTypeNotes
enabledbool
profilestr
isolationIsolationLevel
networkNetworkAccess
limitsCodeExecutionLimits
bindings`tuple[str, ...]None`
workspaceWorkspacePolicy
outputOutputPolicy
artifactsArtifactPolicy
languageLiteral

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.

FieldTypeNotes
cpu_seconds`floatNone`
wall_seconds`floatNone`
memory_bytes`intNone`
file_size_bytes`intNone`
process_count`intNone`

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.

FieldTypeNotes
preview_bytesint
max_bytesint
preserveOutputPreservation

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.

FieldTypeNotes
collectionArtifactCollection
max_countint
max_total_bytesint

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.

MemberValue
ISOLATEDisolated
PROCESSprocess

NetworkAccess

enum

class NetworkAccess(*values)

Whether the program may open its own sockets.

MemberValue
DENIEDdenied
UNRESTRICTEDunrestricted

WorkspacePolicy

enum

class WorkspacePolicy(*values)

What happens to the working directory between executions.

MemberValue
EPHEMERALephemeral

OutputPreservation

enum

class OutputPreservation(*values)

What must happen to output too large for the model to see whole.

MemberValue
WHEN_AVAILABLEwhen_available
REQUIREDrequired
NEVERnever

ArtifactCollection

enum

class ArtifactCollection(*values)

Whether files the program writes to its workspace are collected.

MemberValue
COLLECTcollect
IGNOREignore

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

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

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

FieldTypeNotes
grantLiteral
preregistered_client_id`strNone`
client_secret_credential`strNone`
issuer`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]' = (), 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.

On this page