Skip to content

6 · How interactions travel

Build worlds · Chapter 6 of 12 · Path home

When one agent gets another to do something, an interaction carries the request from start to finish. You met send already; this chapter opens it up, because once you understand how a request travels, every world in the examples becomes readable.

The shape of it

send(...) creates an Interaction: "please run this action, on these streams, and tell me when you're done." The target runs it, results flow back on the bound streams, and a completion status returns. The whole thing is paced by the clock, one step per tick.

See both sides, with diagrams

The crucial subtlety, that a request only runs if the target's behavior accepts it, plus worked two-agent and N-agent flows, is laid out with diagrams in How a request actually works and the communication scenarios. Read this chapter for the what, that page for the who does what.

Two ways to send

The first decision is whether you are asking the target to run an action, or just handing it data.

Pass an action_name. The target runs that action on what you send. The chat user asks the broadcaster to run its broadcast_message action:

await self.send(action_name="broadcast_message",
                target=[self._broadcaster_peer_id],   # the broadcaster it connected to
                streams=[my_processor_output_hash],   # a handle to its OWN output stream
                num_steps=samples, copy_sys=True)

Two identifiers here are worth naming. self._broadcaster_peer_id is just the peer the user found and connected to earlier (connect_by_role, Chapter 7). my_processor_output_hash is a handle to the user's own output stream, the text it produced, which it is asking the broadcaster to read; you obtain it from get_stream("processor") (its props carry the hash). You never invent these strings by hand: you look a peer up by role, and a stream up by name.

Pass no action_name. The target simply stores the data you send, no action runs, no transition fires on its side. The broadcaster fans a message out to everyone this way:

await self.send(target=other_users,
                data_samples={"proc_output_0": f"**{sender}:** {msg}"},
                num_steps=1)

New to proc_output_0? It is an agent's fixed output slot, An agent's own streams explains the naming convention in one place.

That single distinction, run something versus here is data, underlies relay, teaching, and peer learning alike.

Focus · what happens on the wire, line by line

Read the first call as four decisions, because every send you ever write is the same four:

  1. action_name="broadcast_message" names the action you want the target to run. The string must match an action that the broadcaster's class actually defines, and (crucially) one its behavior is currently willing to run, more on that just below.
  2. target=[self._broadcaster_peer_id] says who. A list of peer ids, here a list of one. self._broadcaster_peer_id is not a string you typed: the user found the broadcaster earlier by role and stashed its id (Chapter 7).
  3. streams=[my_processor_output_hash] binds your own output stream to the request. You are not handing over a copy of the text; you are attaching a live handle to the stream the text lives on, which the framework routes to the broadcaster's stdin.
  4. num_steps=samples + copy_sys=True set the rhythm and the payload: num_steps is how many samples flow (one per tick), and copy_sys lifts the value you just produced (with process()) into this fresh interaction so it actually rides along. Without copy_sys, the new interaction would start empty.

When send runs, the framework builds an Interaction object, resolves the targets, and dispatches it. On the broadcaster, the request arrives, its broadcast_message action reads your stream off its stdin, and a completion status travels back to the user. That return value is what lets the user's behavior move on.

The pure data push drops decision 1 entirely. With no action_name, the target runs nothing: the framework just deposits your data_samples onto the target's input stream and that is the end of it. num_steps=1 ships exactly one sample. Use this when the receiver already has a behavior watching that stream and you only need the value to be there (the chat broadcaster's downstream users are exactly that case).

A request only runs if the target accepts it

This is the single most important thing to internalise about send, and it is easy to miss because nothing in the caller hints at it. Calling send(action_name="broadcast_message", ...) does not force the broadcaster to run broadcast_message. The request only fires if the broadcaster's behavior, in the state it is currently in, has a transition that accepts it, a self-loop declared with ready=False:

# in the chat world's behavior for the broadcaster role:
behav.add_transit("ready", "ready", action="broadcast_message", args={}, ready=False)

ready=False is what marks a transition as "do not fire this on my own clock; fire it only when someone sends me a matching request." So the contract has two sides: the caller names an action and a target, and the target must be sitting in a state whose behavior is willing to run that action on request. If it is not, the request simply waits or expires, it never barges in. The full two-agent and N-agent picture, with diagrams, is in How a request actually works.

The parameters that matter

send has many options; these are the ones you will actually reach for.

action_name
The action the target runs, or None for a pure data push (above).
target
One peer id, a list of them, or a wildcard like "<valid_cmp>" (the agents that just passed an evaluation). One send can address a whole group.
streams
Which streams to bind for this request. They fill the routing slots, see below.
data_samples
Concrete values to ship directly, often a {stream_label: value} dict, instead of (or alongside) streams.
num_steps
How many steps this interaction runs. -1 is a one-shot control message; >0 means it spans that many samples over several ticks (a 998-token lesson is num_steps=998).
copy_sys
Lift the output the agent just produced into this new interaction, so a value computed in one step can be shipped in the next. The chat user uses it to forward the reply it just generated.
wait_completion
Keep the request "live", re-checking each tick, until every target reports done. This is how a teacher blocks on "all students finished this lesson".
callback
The name of an action to run automatically when the request completes, the basis of the round coordination in Chapter 9.
volatile
Fire and forget: the target is asked not to send a completion status back.
timeout
Seconds before the interaction is auto-expired, so one slow peer cannot stall a group.

The complete signature, with every parameter, is in the built-in actions reference.

Two of these change who waits for whom

Most parameters describe the request; two describe how the caller behaves afterwards, and they are worth separating in your head:

  • wait_completion=True keeps the calling action returning False (the Chapter 5 "retry is the wait" pattern) until every target reports done. The framework re-enters your action each tick, checks the tracked interaction, and only returns True once it is completed. Use it when the next step genuinely cannot start until the work is finished, a teacher waiting on "all students done".
  • callback="some_action" does the opposite: the caller does not block. It fires off the request and registers an action to run automatically when the completion comes back later. This is how round coordination stays non-blocking (Chapter 9). Note the framework forbids pairing callback with volatile: a fire-and-forget request sends no completion, so the callback could never run.

If you want neither, you send and move on, and gate a later state on all_sent_completed when you eventually need to know everything landed.

Stream routing: where the data goes

An interaction connects specific streams for the duration of the request, into named slots:

Slot Role
stdin the input the action reads
stdtar the target (e.g. ground truth for learn)
stdout where the result is written
stdext / stdunk extra / auxiliary streams

You usually do not fill these by hand; you pass streams (or data_samples) and the framework matches them to the right slots. The teacher's learn request, for instance, binds the lecture to stdin and the same stream to stdtar so the student trains to reproduce it:

await self.send(action_name="learn",
                streams={"stdin": ["<playlist>"], "stdtar": ["<playlist>"]},
                num_steps=998, wait_completion=True)

Walk this one slot by slot, because it shows the explicit form of streams (a dict keyed by slot, instead of the bare list the broadcaster used):

  1. action_name="learn" asks the student to run its learn action, the built-in training step. As always, this only fires if the student's behavior is sitting in a state that accepts a learn request.
  2. streams={"stdin": [...], "stdtar": [...]} fills two slots by hand. The same "<playlist>" stream is bound to both stdin (what the student reads) and stdtar (what it should reproduce), so the student trains to output exactly the lecture it is being fed. Binding the same stream to input and target is the essence of "learn to reproduce this".
  3. num_steps=998 makes this a long, multi-step interaction: 998 samples flow, one per tick, so the lesson is consumed over time rather than in one blob.
  4. wait_completion=True holds the teacher in place until the student has worked through all 998 steps and reported done, before the teacher moves on (to the exam, the next lesson, and so on).

On the target side, process reads stdin, runs the processor, and writes stdout; learn does that and then a backward pass against stdtar.

Focus · what the data_samples key names, and where the value lands

data_samples={"proc_output_0": value} is the one part of send that trips people up. The key is not the recipient's slot, it is a stream you own. The framework writes value onto that stream of yours and ships it with the interaction, where it arrives on the target's stdin for its action to read. The canonical key is proc_output_0, your own output slot (the very one process writes to), which is exactly what the chat broadcaster pushes above. So the rule is simple: push from a stream you own; the target reads it as input. The pull-versus-push version, worked line by line, is in Chapter 11.

Multi-step interactions

A single interaction can run for many ticks. With num_steps=N, the target processes N samples, one per step, and the interaction completes after the last one. This is how a lecture, an exam, or a generated signal flows: not as one big blob, but as a stream the agent consumes over time, the heart of the Collectionless AI idea that intelligence happens over time, not in a single shot.

The lifecycle, and the manager

Every interaction moves through a defined sequence of states so both sides always know where it stands:

sequenceDiagram
    participant R as Requester
    participant T as Target
    R->>T: Interaction(action, streams, num_steps)
    Note over T: received, then running
    loop each tick, num_steps times
        T->>T: run one step (read stdin, act, write stdout)
    end
    T-->>R: completion status (+ optional callback)

Internally an interaction is created, then requested (on the sender) or received (on the target), then running, then completed with a reason (ok, timeout, rejected, disconnected). You never drive this by hand: each agent's InteractionManager registers interactions, matches their streams to the routing slots, applies timeouts, advances them every tick, and fires callbacks on completion. Your job is to call send with the right options; the manager does the rest.

Focus · when does a received request actually get to run?

On the target, an interaction does not run the instant it arrives. Every tick the manager asks whether it is doable, and it is doable only when three things hold at once:

  1. it is not already completed;
  2. the requester is a known agent (you cannot be driven by a peer you are not connected to); and
  3. all of its bound streams carry fresh data, a sample the target has not yet consumed for this interaction.

Point 3 is why nothing fires on a tick where no new sample has arrived: the request waits, harmlessly, until there is something to act on. Combined with the ready=False acceptance rule above, this is the whole story of why a request runs when it does: the right state must accept it, and there must be fresh data to feed it. Everything else, the timeouts, the per-target completion tracking, the callbacks, the manager handles so your send stays a single line.

What just happened

You can now read any send in the examples and know what travels where: whether it runs an action or pushes data, which streams bind to which slots, how many steps it spans, and how completion comes back. That is the language the whole next half of this path speaks.

Where next