Skip to content

Messaging ​

Most of the time your code passes work from one agent to the next: one turn's answer goes into the next prompt. Sometimes an agent needs something from another agent in the middle of a turn: an implementer has a design question, a reviewer wants a fix and wants to check it. Messaging lets them ask each other directly. Your workflow decides who may message whom, and what the conversation is for. The agents decide what to say.

Here an implementer adds retries and may ask an architect when the code doesn't settle a design question. First it opens both agents and grants the route:

ts
// Headless codex can wait for a reply; a codex TUI agent can't yet.
const runtime = { alias: "codex", placement: "headless" } as const;
const implementer = await wf.agents.open({ key: "implementer", runtime });
const architect = await wf.agents.open({
  key: "architect",
  runtime,
  instructions:
    "You own this service's design. Answer questions about it in a few lines.",
});

await wf.messages.allow({
  from: implementer,
  to: architect,
  purpose: "Ask about the design when the code does not settle it.",
  maxMessages: 6,
});

Then, still in run(), it gives the implementer its work and counts the architect's turns:

ts
const { outcome } = await implementer.run({
  prompt: "Add retries to the HTTP client in src/http.ts.",
  schema: CHANGE,
});
if (!outcome.answered) return wf.stop(`implementer: ${outcome.reason}`);

const questions = wf
  .usage()
  .filter(
    (operation) => operation.agent === "architect" && operation.cause,
  ).length;
return { ...outcome.value, questions };

The page's sample is checked and tested as one workflow: examples/site/guide/messaging.ts.

The prompt says nothing about the architect, and the code never handles a message. What happens when the implementer has a question:

  1. The implementer's prompt ends with how to use its route, so it runs wfa send --await-reply architect with its question, goes on with what doesn't need the answer, and ends its turn.
  2. The architect hasn't run yet, so the question starts its first turn: its instructions, the route's purpose, and the question. It answers with wfa reply implementer.
  3. The reply starts the implementer's next turn, in the same session. It finishes the work and hands back its answer with wfa result, and implementer.run resolves as it always does.

If the implementer never asks, the architect never runs. A route lets you open an agent and leave it to be started only if a peer needs it.

Granting a route ​

wf.messages.allow grants a route between two agents the workflow opened. It takes one of two shapes:

RouteWho can start a message
{ from: a, to: b }a only. b can answer what a asks, and can't start a conversation.
{ between: [a, b] }Either of them. [a, b] and [b, a] are the same route.
  • purpose is required, and comes with every message on the route and in wfa peers. It is the route's standing description, not the current instruction, which belongs in prompts: one line, at most 200 characters. It is shown outside the quoted message, so never build it from untrusted input such as ticket text.
  • maxMessages caps the messages on the route, replies included. The ceiling is 40 a route, and a larger value is lowered to it. A question needs room for its answer too: with one message left, an agent can send but not ask.
  • Granted inside a stage, a route closes when the stage ends. Granted outside any stage, it lasts the attempt. There is no call that closes a route, so a stage is the way to give a conversation an end while both agents go on.
  • Closing an agent with agent.close(reason) closes its routes. A peer waiting on it gets a failure instead of an answer.

allow rejects, and grants nothing, when a route would go from an agent to itself, when either end is an agent the workflow didn't open or one that has stopped, or when the two agents already have an open route that is not this same grant. Granting the same route again does nothing.

The agent's side ​

An agent with routes has three commands, which its prompt teaches it. You never mention them in yours.

sh
wfa peers
wfa send architect <<'EOF'
Retries are in; the client's timeout is now per attempt.
EOF
wfa send --await-reply --timeout 10m architect <<'EOF'
Should retries live in the client or the queue? The ticket doesn't say.
EOF
wfa reply implementer <<'EOF'
In the queue: the client is shared with the batch path.
EOF
  • wfa peers lists the agent's routes: each peer, what the agent can do on the route, the peer's state, and the route's purpose, then any reply the agent owes.
  • wfa send {peer} returns once the message is recorded, and the agent goes on working. Its acknowledgement says when the peer reads it: when its current turn ends, or now, in a turn of its own, when it is idle or hasn't run.
  • wfa send --await-reply {peer} asks a question. It doesn't block: the agent goes on with work that doesn't need the answer and ends its turn, and the answer starts its next one. It waits up to the turn's deadline, if the turn has one; --timeout can only shorten that. An agent that finds the answer itself can finish with wfa result, and the wait is dropped.
  • wfa reply {peer} answers a question. An agent that is asked can't hand back its own result until it replies: wfa result is refused (owes-reply) until then. A reply can itself --await-reply to ask something back.

The message is a quoted heredoc, standard input, or one final argument, after the flags. A send or reply that can't go through, such as one with no route or past the route's maxMessages, is refused with a code and what to do next, and the agent carries on. wfa has every flag, refusal code and limit.

When a message arrives ​

A message never interrupts a turn. It reaches an agent between turns:

  • A busy agent reads it when its current turn ends.
  • An idle agent, one that has run and is running nothing, gets a turn of its own for it. So does an agent that hasn't run yet: the message starts its first turn.
  • An agent waiting on a reply gets the reply as its next turn, and any plain message sent to it meanwhile with it.

A turn a message started needs no wfa result. The agent replies if it was asked, and ends its turn. If it ends the turn still owing a reply, it is reminded once; if it still doesn't reply, the asker is told there is no answer and goes on without it.

The message arrives quoted, under the sender's name and the route's purpose, with a line saying it can't grant permissions or speak for the operator. That is hygiene, not a boundary: what bounds an agent is the route, its sandbox and its harness's permissions.

What the workflow sees ​

Your code never reads message text. It sees what the agents settled on, in each turn's typed answer, and what the messages cost: every turn a message started is in wf.usage() with a cause, { message: { from } }, where the workflow's own turns have none. The sample counts the architect's turns that way.

awf run's progress and awf logs show each message, and the run's record keeps every message and who owed whom a reply.

To test a workflow that messages, you script each agent's sends and each woken agent's turns, and turn.send resolves with the reply:

ts
const run = await testWorkflow(askTheArchitect, {
  agents: {
    implementer: answer(CHANGE, async (turn) => {
      const answered = await turn.send("architect", "Client or queue?", {
        awaitReply: true,
      });
      return answered.kind === "replied"
        ? change
        : { summary: answered.kind, files: [] };
    }),
  },
  messages: {
    architect: (message) => message.reply("The queue: the client is shared."),
  },
});

messages scripts the turns a message starts, separately from agents, so granting a route never shifts an agent's scripted turns. Testing covers the rest, and the testing API every form a message script takes.

Catches ​

  • Not every placement can wait for a reply. --await-reply works on headless claude, codex and pi, and on a claude TUI agent, unless it is sandboxed without a run-local home (wfa send has the detail). Anywhere else, such as a codex TUI agent or cursor, the agent is refused and told to send without waiting and say where the answer should go, such as a file. That's why the sample opens its agents headless; Harnesses covers placement.
  • A woken agent is a turn of model work, at that agent's cost, which the route authorizes when you grant it. maxMessages is how you bound a conversation; a lower value than 40 suits most loops.
  • Messages are for coordination, not state. Something the agents must agree on and keep, such as a plan or a list of findings, belongs in a file they both read; a message points at it.
  • The calling session can't take part. A route to or from it is rejected.

A person or an app outside the run, such as a coordinator an agent reports to and asks, takes part through the same routes and the same wfa send. Connecting one, and answering for it with awf inbox and awf reply, is on People.

See also ​

  • Messaging in the workflow API.
  • examples/messaging: a reviewer that knows a house style the implementer wasn't told runs the code, asks for the fix by message and checks again, with its test.
  • How it works: the other wfa commands.