Signals — multiplayer agents
A signal is a named message { name, payload, from } addressed to a specific
run and delivered mid-flight. The agent declares a listen point
(const review = await chidori.signal("review")); an outside party — a human UI,
a curl, or another agent — delivers to it (POST /sessions/{id}/signal). A
durable per-run mailbox absorbs signals that arrive before the agent is
listening, so delivery and listening never race. Every consumed signal is
recorded in the call_log, so the whole session replays deterministically.
Signals are the inverse of chidori.input. input is agent-initiated
request/response ("I need an answer, I'll block until I get one"). A signal is
externally-initiated push at an agent-declared listen point ("I'm now
receptive — anyone may send me a review"). Mechanically a signal is input()
with three additions: (1) a name, so many distinct listen points coexist;
(2) the durable mailbox, so delivery and listening can race safely; (3) a
from provenance field, so the trace records who steered the run.
This turns a run from a closed loop into a multiplayer session:
- Steer a long-running agent without restarting it. A human or supervisor agent pushes a correction, a new constraint, or a priority change at the next listen point instead of killing the run and re-paying the expensive prefix. The agent decides where it is safe to be steered.
- Human-in-the-loop beyond approvals. The human can volunteer information the agent didn't explicitly ask for, delivered when the agent reaches a receptive checkpoint.
- Agent-to-agent coordination. A supervisor hands a sub-goal to a worker
mid-run; a peer delivers a result or a "stop pursuing X" message.
fromcarries the sender's agent identity, so coordination is attributable. - Late-arriving external events. A webhook fires, an upload finishes — the event lands as a signal instead of the agent busy-polling.
What makes this more than a message queue: every signal is a host effect in the durable call_log, so a live, multi-participant session is still fully reproducible — you can replay exactly what every human and agent sent, in the order it was consumed, and get the identical run. A raw mpsc channel gives you the first half and throws away the second.
Signals compose with branching: branching forks
one agent into many futures; signals open one run to many senders. See
Composition with branching. Related:
docs/captured-effects-vfs-crypto-timers.md,
docs/architecture.md.
API
Agent-facing
import { chidori } from "chidori:agent";
type Signal<T = AgentJson> = { name: string; payload: T; from: SignalSender };
type SignalSender = { kind: "human" | "agent"; id: string; runId?: string };
type SignalTimeout = { name: string | null; payload: null; from: null; timedOut: true };
// Blocking: pause at a named listen point until a matching signal is delivered
// (or already queued in the mailbox). With timeoutMs, resolves to the
// SignalTimeout sentinel after the deadline (discriminate with "timedOut" in r).
chidori.signal<T = AgentJson>(name: string, opts?: {
timeoutMs?: number;
}): Promise<Signal<T> | SignalTimeout>;
// Non-blocking: consume a queued signal if present, else null. Records
// the result (value OR null) so replay is deterministic at this seq.
chidori.pollSignal<T = AgentJson>(name: string): Promise<Signal<T> | null>;
// Fan-in: pause until ANY of the named signals is delivered; the result is the
// bare consumed signal — its `name` says which fired. Pre-arrived candidates
// are consumed in arrival order (lowest delivery_seq across the name set).
chidori.signal<T = AgentJson>(names: string[], opts?: {
timeoutMs?: number; // sentinel `name` is null (no name fired)
}): Promise<Signal<T> | SignalTimeout>;timeoutMs never rejects — a timeout is an expected outcome the agent
discriminates on, not an error. The pause resolves to the
{ name, payload: null, from: null, timedOut: true } sentinel (name is null
for a multi-name fan-in listen, since no name fired). Enforcement is server-side:
an in-process timer armed against a persisted pending_signal_deadline on the
session, re-armed for every paused session at server startup. A delivery that
lands first wins; the late timer validates against the stored session and
no-ops. The sentinel is recorded like any signal result, so a timed-out run
replays deterministically.
SDK types
Signal, SignalSender, and signal/pollSignal live on the
Chidori interface in sdk/typescript/src/agent.ts (types only; the runtime
supplies the methods, like the other host methods).
Worked example — a multiplayer policy-doc drafting agent
A team uses an agent to turn a short brief into a publishable policy document. The expensive part is the drafting (several LLM calls + retrieval); the judgment part needs people and a compliance checker. Three participants collaborate on one live run:
- a human editor (Mara) who reviews drafts and asks for changes,
- a compliance-checker agent that scans each draft and pushes a verdict, and
- a human lead (Sam) who can change the document's priority/scope mid-run.
None of these are agent-initiated input() questions. The editor and the
compliance agent push information when they have it; the lead steers
whenever he wants. The agent only consumes those pushes at the points it
declares safe.
The agent (examples/multiplayer-review/policy_doc.ts)
import { chidori, run } from "chidori:agent";
type Brief = { topic: string; audience: string };
type Review = { decision: "approve" | "changes"; notes: string };
run(async (brief: Brief) => {
let draft = await writeDraft(brief); // expensive: LLM + retrieval
let round = 0;
while (true) {
round++;
await chidori.log(`draft round ${round} ready`, { words: draft.length });
// Open this run to reviewers. The compliance agent AND the human editor both
// send a "review" signal; whichever lands first (or is already queued in the
// mailbox) is consumed here. `from` tells us who reviewed.
const review = await chidori.signal<Review>("review");
await chidori.log("review received", {
from: review.from, // { kind:"agent", id:"compliance-bot" } or { kind:"human", id:"mara" }
decision: review.payload.decision,
});
if (review.payload.decision === "approve") {
return { status: "published", rounds: round, approvedBy: review.from, draft };
}
// A reviewer asked for changes — revise and loop. Before revising, opportunistically
// pick up any steering the lead pushed (non-blocking; null if none waiting).
const steer = await chidori.pollSignal<{ priority: string; scope?: string }>("steer");
if (steer) {
await chidori.log("scope changed mid-run", { from: steer.from, ...steer.payload });
brief = { ...brief, ...steer.payload }; // re-scope without restarting
}
draft = await revise(draft, review.payload.notes, brief);
}
});Two listen points, two very different ergonomics:
chidori.signal("review")blocks — the agent has nothing to do until a review arrives; it pauses, persists, and the run idles cheaply on disk.chidori.pollSignal("steer")is non-blocking — the lead's steering is optional; the agent checks the mailbox and moves on if it's empty.
The senders
A human (Mara) delivers a review from a UI or curl:
curl -XPOST localhost:8080/sessions/$RUN/signal -d '{
"name": "review",
"payload": { "decision": "changes", "notes": "Tighten the data-retention section." },
"from": { "kind": "human", "id": "mara" }
}'The compliance-checker agent delivers its verdict by calling the same endpoint — it is just another participant, identified as an agent:
// inside the compliance agent, after scanning the draft it fetched
await fetch(`${chidoriUrl}/sessions/${targetRun}/signal`, {
method: "POST",
body: JSON.stringify({
name: "review",
payload: { decision: violations.length ? "changes" : "approve", notes: summarize(violations) },
from: { kind: "agent", id: "compliance-bot" },
}),
});The lead (Sam) steers at any time — even while the agent is mid-revision and
not yet listening. The signal lands in the mailbox and is consumed at the
next pollSignal:
curl -XPOST localhost:8080/sessions/$RUN/signal -d '{
"name": "steer",
"payload": { "priority": "high", "scope": "EU + UK only" },
"from": { "kind": "human", "id": "sam" }
}'The trace
Each signal is a recorded host call, so the multiplayer session streams as one
trace with every participant attributed by from:
agent.run policy_doc
├─ tool.call writeDraft
├─ host.log draft round 1 ready
├─ host.signal review ← idles here; resolves when a review lands
│ from=agent:compliance-bot decision=changes
├─ host.log review received (compliance-bot)
├─ host.signal steer (poll) ← from=human:sam (was queued before the agent looked)
├─ host.log scope changed mid-run (sam)
├─ tool.call revise
├─ host.log draft round 2 ready
├─ host.signal review ← from=human:mara decision=approve
└─ … published, approvedBy=human:maraWhy durability matters here
chidori trace $RUN gives a complete, ordered audit: who reviewed each draft,
what they said, when the lead re-scoped, and which reviewer's "approve"
published the doc. And chidori resume policy_doc.ts $RUN (or any replay)
reproduces the identical run — the editor's notes, the compliance verdict,
and the steering come back from their recorded CallRecords, so a later "why
did it publish?" investigation re-derives the exact decision path without
re-contacting any human or re-running the compliance agent. A live,
multi-participant collaboration that is also a deterministic, auditable
artifact.
Delivering signals
POST /sessions/{id}/signal body: { name, payload, from }name is a required non-empty string (400 otherwise); payload is any JSON
(default null); from is an optional provenance object (default null).
The server routes on the run's state:
| Run state | Response | Behavior |
|---|---|---|
| Streaming (a live worker supervises the run) | 202 {"status":"delivered_live"} | The signal is enqueued straight into the live run's in-memory mailbox (write-through to signals/inbox.json in the same critical section) and the worker is woken. A run mid-execution drains it at its next listen point; a run idling on a matching listen point is resolved and resumed in-process — no HTTP round-trip — and the SSE stream stays open across the resume. |
Paused, waiting on THIS name (a Signal op whose {name} matches, or a fan-in listen set containing it) | 200 + updated session view | Resolve + resume: the pending op is resolved with {name, payload, from}, a synthetic signal CallRecord is injected at the pending seq, and the run re-runs to its next pause or completion — the same machinery /resume uses. |
| Paused on a DIFFERENT name / on input / on approval, or Running (no live worker) | 202 {"status":"queued"} | Enqueue into signals/inbox.json with an assigned delivery_seq. The run stays where it is; the entry is drained when it reaches a matching listen point. |
| Completed / Failed / Cancelled | 409 Conflict | No inbox write — an orphan inbox would mislead a later replay. |
Same-name tie-break: pending-pause-wins-with-newest. If the run is paused on
name X and a same-name entry is also already queued in the inbox, the pending
pause resolves with the just-delivered signal; the older queued entry stays in
the inbox for the next listen point. The live worker preserves the same rule by
taking the just-delivered entry back out of the mailbox by its delivery_seq.
Input and approval pauses end live supervision and hand off to the durable HTTP
endpoints; terminal states close the stream with the usual done event.
How it works
A signal is a named, externally-deliverable flavor of an input pause, fronted
by a durable mailbox, built on the same suspension substrate as every other
pausing host call (PendingHostOperation / HostPromiseTable / pending.json).
The listen point. When the agent calls chidori.signal(name), the runtime
(execute_signal in crates/chidori/src/runtime/host_core.rs), in order:
- Replays a recorded result if this seq is already in the journal.
- Returns a completed host-op if one matches
(seq, PendingHostOperationKind::Signal, args = {name}). The match key is the name only — the payload is unknown at pause time;{name, payload, from}live in the recorded result, mirroring howinputkeeps the prompt in args and the answer in the result. - Drains the mailbox — if a queued signal of that name exists, consumes it without pausing and records a completed call.
- Otherwise pauses: persists the pending op at a safepoint, sets a
PendingSignal { seq, name, id }on the context (a sibling ofPendingInput), and throws the pause marker. The engine surfaces the pause asRunResult { paused_signal: Some(PendingSignal { seq, name, .. }) }, so the server knows which named op is waiting.pollSignalstops after step 3, recording the value ornull; the fan-insignal([...])pauses on a name set (match key{names: [...]}) and drains the lowest-delivery_seqentry across the whole set.
The mailbox. .chidori/runs/{id}/signals/inbox.json is an ordered
Vec<QueuedSignal { name, payload, from, delivery_seq, enqueued_at }>.
delivery_seq is a monotonic counter assigned at delivery time, freezing global
arrival order across all senders. It is a standalone file (like pending.json),
not a manifest field, because the HTTP endpoint writes it while the run is not
live. In memory, the RuntimeContext carries the inbox (loaded at run/resume
start, threaded the same way the VFS is). take_queued_signal(name) removes the
lowest-delivery_seq matching entry and persists the shrunken inbox inside the
same critical section that records the completed call — so a crash cannot
double-deliver; on restart the recorded result wins and the inbox is never
re-drained for that seq. Concurrent deliveries to one run are serialized by a
per-run inbox lock.
Resume. Delivery to a matching pause reuses the /resume machinery:
complete_persisted_pending_host_operation resolves the persisted op, a
synthetic signal CallRecord is injected at the pending seq, and
run_replay_pausable_with_host_promises_vfs_and_signals re-runs the agent to
its next pause or completion. The resume worker loads the inbox alongside the
host promises and VFS, so a resumed run that reaches a second listen point
drains any still-queued entry instead of pausing again.
Live delivery. For a streaming run, the supervising worker is a supervision
loop: a signal() pause persists durably, emits a paused SSE event, and keeps
the stream open; the worker selects on the signal wake channel, cancellation,
and any timeoutMs deadline, and resolves a matching pause in-process (resolve
persisted op + synthetic record + replay re-run — the same shape as /resume,
minus the HTTP latency). Determinism is unchanged: the live path is still a
deterministic resume re-run.
Tracing. A signal call is recorded like any host call, so it carries
parent_seq and streams as a span under its parent. name and from ride in
the record and are stamped as OTEL span attributes (signal.name,
signal.listen_names, signal.from.kind/id/run_id, signal.timed_out) on
signal/poll_signal/signal_any spans.
Determinism
A signal recorded in the call_log replays identically, regardless of whether it was delivered by pause-resume, consumed from the mailbox, or resolved by the live worker.
- The match key is deterministic.
(seq, PendingHostOperationKind::Signal, args = {name}):seqcomes from the deterministicnext_seq()walk, so it is identical on every replay (inductively — the pre-signal prefix is deterministic, so the signal lands at the same seq);kindandnameare structural.completed_operation(seq, Signal, {name})and the replay check find the same record every time. - The result is read from the log, not the world.
{name, payload, from}comes verbatim from theCallRecord— neither the inbox nor the endpoint is consulted for an already-recorded seq. The replay and completed-op checks short-circuit before the mailbox drain is ever reached, so a replay run can have an empty inbox and still reproduce a consumed signal. The log is the source of truth; the inbox is a live-only convenience. Replay never re-reads the inbox. - Ordering is captured two agreeing ways. Across different listen points,
seqtotally-orders consumption by the agent's own control flow, independent of arrival timing. For same-name signals competing for one listener, the lowest-delivery_seqqueued entry is consumed and that choice is frozen into the result. Two same-name signals arriving before twosignal(name)calls: the first call consumesdelivery_seqN, the second consumes N+1, both recorded; replay reproduces both from their records.
Consumption removes the entry from inbox.json and writes the CallRecord
in one critical section, so enqueue-then-consume is crash-safe and a run that
pre-arrived its signals produces the identical final call_log as one that
paused and was resumed.
Signals are consumed only at agent-declared listen points — never as preemptive interrupts that could fire at an arbitrary instruction. That is the determinism contract: delivery timing is free, consumption points are the agent's, and everything consumed is recorded at a deterministic seq.
Edge cases
- Signal to a completed/failed/cancelled run:
409 Conflict, no inbox write. - Two same-name signals with the run paused waiting on that name: the
pending pause resolves with the newly arrived signal
(pending-pause-wins-with-newest); the older queued entry stays for the next
signal(name). - Concurrent delivery and resume:
inbox.jsonread-modify-write is guarded by a per-run lock, so an endpoint enqueue cannot race a resume worker or the live worker's drain. fromprovenance:from = {kind, id, runId?}rides in theCallRecord.result, so it is in the durable log, rides the normal call events, and is stamped on the OTEL span.
Composition with branching
The mailbox is per-run, not per-branch — a branch (see
docs/branching-execution.md) listening on
signal(name) drains the shared parent inbox. Each branch's reserved
CallLogSequenceRange means its signal CallRecords stay in-range
automatically, so determinism composes. Together the two primitives give the
full picture: branch to explore N futures, signal to let participants
steer or pick among them.