Skip to content

The reflex graph

A message arrives, an answer goes out, and between the two something decides which agent runs, where, with what context, and what happens when the agent stops to ask. That something is the reflex graph, a pydantic-graph in octomate/reflex/.

Declared, not dispatched

Every edge comes from a node's own run return annotation. A node that can hand off is typed as returning Summon; one that ends returns End. There is no dispatcher table, so the graph's shape is read off the nodes, and the builder validates that every node is reachable from the entry.

flowchart TD
  START([kick]) -->|message, command or resume batch| Awake
  START -->|live batch| Live[Deliver to the owning request]
  Awake -->|user message| Route
  Awake -->|explicit command| Command
  Awake -->|resolved batch| ResumeDeferred
  Awake -->|native scheme| Scheme
  Awake -->|thread summon API| Summon
  Awake -->|thread teleport API| Teleport
  Route --> React
  React -->|summon| Summon
  React -->|scheme| Scheme
  React -->|teleport| Teleport
  React -->|waiting on a batch| Deferred([End: suspended])
  React -->|done| Done([End: result])
  Command -->|direct outcome or completed run| Done
  Command -->|summon| Summon
  Command -->|scheme| Scheme
  Command -->|teleport| Teleport
  Command -->|waiting on a batch| Deferred
  Summon --> React
  Scheme --> React
  Teleport -->|resumed where it landed| React
  Teleport -->|landed, not resumed| Done
  ResumeDeferred -->|batch still incomplete| Deferred
  ResumeDeferred -->|already resumed| Done
  ResumeDeferred -->|answers as tool results| React

The nodes

Awake resolves the signal once and writes the source context into state. Five signals enter here: a user message from a channel, a resolved action batch coming back from a card, a spell a native session cast over MCP, and an authenticated thread operation the console asked for, validated by the gateway, and an explicit command on a resolved conversation. A thread operation, and a native session's teleport of its own thread, enter at the thread they act on and go to Summon or Teleport directly. Awake consumes its signal into resolved run context; downstream nodes do not retain the signal. A UI teleport opens a fresh turn with an address notice; it does not fabricate a deferred tool result. A message enters the thread its address names, or a fresh sub-thread when the address is a chat room. See Threads and chat rooms.

Route decides who runs this turn, in order: the agent a handoff already pinned on the chat; the channel's default agent when the message is already in a flat thread; otherwise the channel's default agent, in the same conversation, with no handoff recorded, because a group's main surface is never pinned. There is no separate triage pass: the entry agent self-routes with the gateway if it wants to.

React starts ordinary agent runs. It resolves the agent against the channel the run will happen on, records the handoff if one is pending, mounts the gateway and the user's capabilities, registers the session at the gateway so a second concurrent turn is refused, then runs the agent. The run always streams, whatever the channel: what it does goes out on the nerve as it happens, and the channel decides whether to draw it live or only its reply. After the run it reads what the gateway recorded: a summon becomes Summon, a scheme becomes Scheme, a teleport deferral becomes Teleport, any other deferral ends the graph suspended, and a plain result ends it. Whatever happened, the turn's workspace is saved.

Command executes the selected runtime command directly, bypassing Route and chat prompt construction. The node enters the manager's validate scope, prepares the user directly, then calls execute. The validation scope holds the turn guard through setup and execution. Refused and replayed deliveries skip user preparation; execution records the receipt before invoking the agent. A setup failure releases the guard without recording a command. Direct output is presented as command feedback and ends with a CommandResult or CommandError, without a model run. A command that starts agent work keeps that same invocation open while Reflex consumes its events; it never calls agent.run a second time. Direct commands leave the summon decision unset. An agent stream or deferral creates its continuation decision when needed, preserving the selected agent and model for resume or teleport.

React and Command use the stateless ctx.deps.runtime service for gateway capabilities, deferred approvals, publishing the run, reply bindings and post-run transitions. State records the selected agent/model and conversation. Each entry creates its own session, suspender and capability list; the runtime retains none of them across handoffs. Both streaming and non-streaming channels receive replies. A command's actual agent run can hand off, scheme or teleport, and deferred batches resume through the existing ResumeDeferred path. Accepted command attempts save their workspace on exit; rejected and replayed deliveries do not prepare user tools or save it. The inner React graph and its stream contract remain dedicated to agent runs.

Commands submitted through the HTTP API enter this graph for both Trunkline and IM destinations. Reflex publishes direct feedback, agent output and approval requests on the nerve, where the destination channel draws them and a console watching the thread sees them; the HTTP caller receives completion after the graph finishes.

Explicit command receipts stay in the visible ledger but are omitted from pending chat input and automatic room recaps. Their arguments are not another chat turn.

Summon hands the current conversation to the agent the decision names, where it is, then re-enters React with the brief as the new agent's prompt. Moving a conversation elsewhere is Teleport's.

Scheme opens the asking user's direct messages with the hint, finds who already owns that DM or falls back to that channel's default agent, and re-enters React as an ordinary hand-off with the brief.

Teleport carries the same agent somewhere else: asks the channel to create a thread at the prepared address, forks the conversation's messages into the landed thread, binds the project and forks its workspace if one was named, or carries the source thread's project and the tree it stands at when none was, relocates the agent's session, and re-enters React when there is more to do there: the console's prompt, recorded as the user's message, or, when the agent asked to carry on, the teleport call answered by a sentence saying where it now is. Otherwise the move ends there, with the handoff recorded and the workspace saved. A failed open refuses the move; it does not resume at the source. For native history, Awake asks the receiving agent to validate the source before entering Teleport, so an unusable transcript cannot create or announce a destination.

ResumeDeferred takes a resolved batch, rebuilds the target from what the batch persisted, finds the thread through the conversation rather than the address (a chat-room kick ran in a sub-thread), restores the user's profile so the resumed run keeps the same capabilities and prompt prefix, and re-enters React with the batch's answers as tool results and no new prompt.

Publishing, not presenting

The graph draws nothing. A kick opens a stream on the nerve, asked on the thread the signal came from, and everything the graph does goes out on it as it happens:

  • The reflex wakes before anything else and falls asleep last, whatever happened: reflex_awake and reflex_asleep bracket the whole kick. A thread or chat room holds one reflex at a time: a kick asked on one whose reflex is still awake is refused before it wakes.
  • Each agent run is bracketed by run_started, which names the thread the run reports into, and run_ended. One kick can hold several runs: a summon runs the new agent after the first, and a teleport can resume where it landed.
  • A move leaves its line in the chat it left, a send lands where it was sent, and a turn that fails reports its error where it came from.

The graph records before it publishes: a run's reply, a move's line and a send delivered somewhere else are rows in the ledger before their events go out, and each event names its row, so the channel that draws it can mark it with the platform id it got. A rendering that fails cannot fail the turn.

Each event is heard on the thread it happens at, by that channel and by any console watching the thread, and on the thread the kick was asked on, so a console follows the kick wherever a move takes it. Something sent to a person's private surface, a send into their direct messages or an authorization link, is heard only where it lands. See Feelers for how a channel draws what reaches it.

Suspending and resuming

A run ends suspended when its output is a set of deferred tool requests the run could not resolve itself. The suspender classifies them by the metadata each deferral declares, never by tool name: a teleport kind goes back to the graph, and everything else becomes a persisted batch with every address, mode and decision the graph needs to come back. A teleport has already passed the agent's own permission check by then, so nothing asks about it again. The batch goes out on the run's stream as one event, and the channel presents its cards wherever it draws the run.

The batch is a row. When the cards are answered, possibly after a restart, the channel kicks the graph with the batch id, ResumeDeferred rebuilds the state from the row, and React resumes the agent's conversation. Inkling resumes through the graph with the answers as tool results; a harness that takes no tool result back is given the answers as its next prompt. An agent that parks a live process instead, as the harness bridges do, pauses on the suspender rather than ending. Each batch records which it is: resume re-enters the graph; live delivers the answer to the waiting agent. A live batch whose agent is no longer waiting is an error, never a reason to start another run. The agent is waiting before the batch's cards are shown, so an immediate response reaches it. Once a batch is answered, the answer goes out as well, from the live run that takes it or from ResumeDeferred, so a console still showing the cards marks them answered.

The inner graph

Inkling has a second, smaller graph per turn: start or resume, run the agent, resolve deferrals. A deferral is first offered to the conversation's posture, which under dontAsk or bypassPermissions answers it in process, and only what is left reaches the suspender. That is why an Inkling run can keep going through an approval that the posture already granted, and why a whole batch goes to the human when any part of it needs one.

The graph runs inside a single kick, under one Arcanus materia context, with Logfire spans per node, and publishes on the stream that kick opened. The state object carries the resolved source, the target, the pending handoff, the user profile and the prompt; nodes read it and carry only transition discriminators of their own.