Chidori

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. from carries 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:mara

Why 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 stateResponseBehavior
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 viewResolve + 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 / Cancelled409 ConflictNo 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:

  1. Replays a recorded result if this seq is already in the journal.
  2. 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 how input keeps the prompt in args and the answer in the result.
  3. Drains the mailbox — if a queued signal of that name exists, consumes it without pausing and records a completed call.
  4. Otherwise pauses: persists the pending op at a safepoint, sets a PendingSignal { seq, name, id } on the context (a sibling of PendingInput), and throws the pause marker. The engine surfaces the pause as RunResult { paused_signal: Some(PendingSignal { seq, name, .. }) }, so the server knows which named op is waiting. pollSignal stops after step 3, recording the value or null; the fan-in signal([...]) pauses on a name set (match key {names: [...]}) and drains the lowest-delivery_seq entry 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}): seq comes from the deterministic next_seq() walk, so it is identical on every replay (inductively — the pre-signal prefix is deterministic, so the signal lands at the same seq); kind and name are 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 the CallRecord — 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, seq totally-orders consumption by the agent's own control flow, independent of arrival timing. For same-name signals competing for one listener, the lowest-delivery_seq queued entry is consumed and that choice is frozen into the result. Two same-name signals arriving before two signal(name) calls: the first call consumes delivery_seq N, 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.json read-modify-write is guarded by a per-run lock, so an endpoint enqueue cannot race a resume worker or the live worker's drain.
  • from provenance: from = {kind, id, runId?} rides in the CallRecord.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.