Psych Runtime
Guides

Pause a run and resume it later

A Run that needs something outside itself suspends: it releases its lease, persists, and waits. Nothing is held in memory. The process that resumes it need not be the one that...

A Run that needs something outside itself suspends: it releases its lease, persists, and waits. Nothing is held in memory. The process that resumes it need not be the one that asked, and may be on another machine.

The alternative, holding the lease and polling, is exactly what this removes. A fan-out of four children would pin four Workers doing nothing while their own children queue behind them.

Approvals, questions, webhook waits, waiting on children, a workflow's timers and its breakpoints are one mechanism, because they all run through the same lease and log machinery.

Six reasons

SuspendReasonWaiting onDefault expiry
APPROVALA human decision on one tool call, or on a workflow tool step24h
QUESTIONA person's answer to ask_question, or to a workflow human step24h
EXTERNALA webhook, a callback: anything outside Psych, including a workflow wait step's event7 days
CHILDRENBackground subagents this Run spawned1h
TIMERA workflow sleep step or a retry backoff. Nobody answers it: the Run is parked with a wake time and claimed again when it passeswake + 7 days
BREAKPOINTA person saying "go on" to a workflow paused before a step24h

TIMER and BREAKPOINT are produced only by workflows (psych-workflows). A timer parks the Run as RUNNABLE with runnable_at set rather than as SUSPENDED, because nothing will ever call resume() for it; the claiming Worker writes the resumed record itself. Every other reason parks the Run SUSPENDED until a resume arrives.

Which step is waiting

When the wait belongs to a workflow step, the suspended record names it (step_id, step_name, event, wake_at) and status() projects it: status.suspended_step_id, plus one of pending_approval (call_id=None, step_id set, the tool and its resolved arguments), pending_question (call_id=None, step_id set), or pending_wait (reason, event, payload_schema, wake_at, expires_at) for an event, a timer or a breakpoint. psych_runtime.workflow_view(store, run_id).waiting says the same thing inside the step tree.

CHILDREN is a distinct reason rather than a reuse of EXTERNAL, and the difference earns the enum member. Every other suspension waits on something Psych cannot see, so nothing in the library can say when it ends or whether it ever will. This one waits on Runs Psych admitted, can name, and can read. That buys two things: a parent about to suspend reconciles its children first and does not suspend at all if they have already finished, and a reader can be told what it is waiting for by name.

Asking a person a question

Off by default. Turn it on per Spec:

spec = psych_runtime.AgentSpec(
    name="support",
    model=psych_runtime.ModelRef(model="gpt-4o"),
    suspension=psych_runtime.SuspensionPolicy(may_ask_questions=True),
)

The model then has ask_question. It is the one built-in whose body never runs: it is a request to stop. The agent loop intercepts it at the same gate an approval passes through, writes a suspension and returns. What comes back is not a return value but a person's answer, delivered through psych_runtime.resume(payload=) on whatever Worker picks the Run up next.

A question is not a bare string. The model passes up to four questions, each with the question text, an optional short header for a compact label, and up to four {label, description} options. An open text box makes the person guess what an acceptable answer looks like and makes the model parse prose that may not contain one. Options move both problems to where they are cheap.

Options never trap anyone. They are a suggestion, not an enumeration. A person may always answer in their own words, and the answer comes back as free text either way. Nothing validates the reply against the option list. Render the options as the easy path and keep a way to type something else.

Four questions and four options is the cap, in the field definitions rather than in advice, because advice in a tool description is a suggestion a model may take or leave. A prompt asking eight questions at once is a form, and people abandon forms.

Reading and resuming

status = await psych_runtime.status(store, run_id, scope=scope)
if status.suspend_reason == "question":
    q = status.pending_question
    await psych_runtime.resume(store, run_id, payload={"answer": "the Berlin one"}, by="user-42")

if status.suspend_reason == "approval":
    await psych_runtime.resume(store, run_id, approved=True, by="manager-7")

by= is who decided, as you identify people. It is recorded on the resumed record and interpreted by nothing. It exists because an approval whose log cannot say who approved it is not an audit trail.

scope= is whose decision it is. Like every read entry point, resume() refuses a Run belonging to another tenant with AccessDenied. It is optional so existing callers keep working; a consumer serving end users should pass it.

Waiting on something external

Suspend for EXTERNAL, park your correlation id, and resume from your webhook handler:

async def on_provider_callback(event: dict) -> None:
    await psych_runtime.resume(store, RunId(event["run_id"]), payload=event["result"])

Psych stores no cron expressions and runs no scheduler. An agent waiting on a clock is one your own scheduler resumes with resume(), the same way it admits one with dispatch(); suspend it as EXTERNAL, since from the runtime's side a clock and a webhook are the same thing. A workflow sleep step or retry backoff is the one clock the runtime does keep, as TIMER: the store parks the Run until the wake time and any Worker's claim wakes it, so no timer lives in a process.

A workflow wait step is the same webhook pattern with the event named: status.pending_wait.event says which event the Run wants, and the payload you deliver becomes the step's output, checked against the step's payload_schema first.

Breakpoints

A workflow dispatched with breakpoints= or step_mode=True suspends as BREAKPOINT before the named step. resume() with no payload continues; resume(payload={"step_mode": False}) or True turns stepping off or on for the rest of the Run. A resume delivered to a TIMER wakes it early.

Expiry

Past the policy's window, resume() raises SuspensionExpired and the Run is settled ABANDONED first, so a stale approval never executes. Configure it per Spec:

psych_runtime.SuspensionPolicy(
    approval_expires_seconds=86_400.0,
    question_expires_seconds=86_400.0,
    external_expires_seconds=604_800.0,
    children_expires_seconds=3_600.0,
)

Errors

RaisedMeans
RunNotFoundNo such Run.
AccessDeniedscope= names a different tenant than the Run's.
RunNotSuspendedThe Run is not waiting for anything.
SuspensionExpiredThe decision arrived too late; the Run is already ABANDONED.

Gotchas

  • may_ask_questions is inside the Version hash. An agent that may ask is a different agent from one that may not.
  • A suspended Run holds no Worker. Do not poll for it with a Worker; read psych_runtime.status() from your own code.
  • payload and approved are different answers. approved= is the approval decision; payload= is data for a question or an external wait.
  • ask_question is not how to ask permission for a tool call. That is approvals, and the tool description says so to the model.

On this page