Signals
The signal channel is the runtime’s interrupt primitive: a
per-session mailbox of named FIFO queues. Code inside a run parks
on runtime.wait_for_signal(session_id, name); code outside the run —
a web handler, a Slack callback, an operator console — unblocks it
with agent.signal(session_id, name, payload). The payload crosses
over and the run continues.
from loomflow import Agent
from loomflow.runtime import SqliteRuntime
runtime = SqliteRuntime("./journal.db")
agent = Agent("...", model="claude-opus-4-7", runtime=runtime)
# Somewhere inside the run (an approval handler, a custom
# architecture) the task parks:
payload = await runtime.wait_for_signal("sess-42", "approval")
# ...until your application delivers, from anywhere in the process:
await agent.signal("sess-42", "approval", {"action": "allow"})Both InProcRuntime and JournaledRuntime (and its
SqliteRuntime / PostgresRuntime subclasses) implement the channel.
The three entry points
# Deliver — enqueue payload under name, wake one waiter.
await agent.signal(session_id, name, payload=None) # convenience
await runtime.signal(session_id, name, payload) # direct
# Park — return the oldest payload for name, waiting until one arrives.
payload = await runtime.wait_for_signal(session_id, name)
# Poll — pop a queued payload if present; never blocks.
payload = runtime.poll_signal(session_id, name) # sync, -> Any | Noneagent.signal(...) is a delegate to runtime.signal(...) so callers
don’t reach into runtime internals. On a runtime without signal
support it logs a warning and drops the signal (no-op, no exception).
poll_signal has a None ambiguity. It returns None both when
nothing is queued and when the queued payload is itself None. If you
need to distinguish, deliver a sentinel payload ({"ok": True})
instead of None.
Delivery semantics
- FIFO per name. Multiple payloads under one name queue in order;
wait_for_signal/poll_signalpop oldest-first. - One waiter per payload. Multiple tasks parked on the same name are woken oldest-first, one per delivered payload. Which waiter gets which payload is scheduler-dependent, but no payload is ever lost or delivered twice.
- Queue-before-wait works. A signal delivered before anyone waits
is queued; the next
wait_for_signalreturns immediately. - Names and sessions are isolated. A signal for
("a", "sig")never surfaces on session"b"or under name"other". - Cancellation-safe. A waiter cancelled after being woken but before consuming hands its wakeup to the next parked waiter, so the payload is never stranded.
Mailbox lifecycle
The mailbox lives inside the runtime’s session state:
- Created lazily — the first
signal(...)orwait_for_signal(...)for asession_idcreates its mailbox, even before the session context is formally opened. Early signals queue. - Shared across nested contexts — re-entering
runtime.session(session_id)(as concurrent runs on one session do) reference-counts the same mailbox. - Discarded on last exit — when the session’s last open context exits, the mailbox (and any undelivered signals) is dropped, so the sessions dict can’t grow without bound across runs.
Signals are process-local and never journaled — even on
SqliteRuntime / PostgresRuntime, where the journal and checkpoints
are durable, the mailbox is in-memory anyio state bound to one event
loop. Deliver signals from the same process that hosts the parked run.
A signal cannot resurrect a run in a crashed process — that’s what
checkpoints are for.
The park-and-resume pattern
The main consumer is human-in-the-loop approval. Approval handlers are async and the signal channel is public, so a handler can park the run until an out-of-band decision arrives — no framework support needed beyond the signal API:
from loomflow.architecture import ApprovalDecision
async def approve(call, user_id):
await notify_slack(call, user_id) # out-of-band ask
payload = await runtime.wait_for_signal( # park the run
session_id, "approval"
)
return ApprovalDecision(action=payload["action"])
# The run is now parked mid-turn. Your Slack webhook unblocks it:
await agent.signal(session_id, "approval", {"action": "allow"})The model’s pending tool call waits, the transcript stays intact, and
the loop continues the moment the decision lands. See
Approval handlers for the full
ApprovalDecision surface (edit-args, remember-decision).
Inside a custom architecture
Architectures park via the wait_for_user_signal helper, which also
emits an interrupt.waiting architecture event so streaming UIs can
show a “waiting for input” state:
from loomflow.architecture import wait_for_user_signal
payload = await wait_for_user_signal(
deps, session.id, "resume", emit=emit,
)When the runtime has no signal support, the helper logs a warning and
returns None immediately — the run proceeds rather than deadlocking
on a channel that can never be written.
Waiting with a timeout
wait_for_signal parks indefinitely. Bound it with your async
library’s timeout primitive:
import anyio
try:
with anyio.fail_after(300): # 5-minute approval SLA
payload = await runtime.wait_for_signal(sid, "approval")
except TimeoutError:
payload = {"action": "deny"} # fail closedFor long approval windows that outlive the process, don’t park at all:
persist the pending request, end the run, and start a new one when the
decision arrives — combine checkpoints
with agent.resume(...) to continue the transcript where it stopped.