Agent Communication
Keel agents communicate by sending events to each other. The sender posts a named event and returns immediately. The receiver handles it when the runtime delivers it.
How It Works
sequenceDiagram
participant A as Agent A
participant B as Agent B
note over A: @on_start { ... }
A->>B: send(B, data, event: "greeting")
note over A: returns immediately — continues execution
note over B: on greeting(msg: str) { ... }
B->>B: handler runs with msg = data
Event Routing
The event: parameter in send determines which on handler runs on the receiver.
flowchart LR
s1["send(B, data, event: "greeting")"]
s2["send(B, data, event: "payment")"]
s3["send(B, data)"]
h1["on greeting(msg) { ... }"]
h2["on payment(msg) { ... }"]
h3["on message(msg) { ... }"]
drop["silently dropped"]
s1 -->|"event matches"| h1
s2 -->|"event matches"| h2
s3 -->|"default event: message"| h3
s1 -->|"no matching handler"| drop
The default event name is "message" — omitting event: routes to on message.
Asynchronous Delivery
Send and receive are decoupled. Agent A does not wait for Agent B’s handler to finish.
sequenceDiagram
participant A as Agent A
participant Q as Queue
participant B as Agent B
A->>Q: post event (non-blocking)
A->>A: continues own work
Q->>B: deliver event
B->>B: on greeting runs
delegate vs send
delegate, send, and broadcast are built-in agent verbs — always in
scope, no import needed. delegate posts a named handler event to
another agent’s mailbox.
Symbol form (preferred)
delegate(Processor.handle, payload)
# Processor's `on handle` fires with payload
Processor.handle is a compile-time–resolved handler reference. The type checker
validates that handle is a declared on handler on Processor and that payload
matches the handler’s parameter type. Misspelled handler names and wrong argument
types are caught at compile time, not at runtime.
String form (legacy)
delegate(Processor, "handle", payload)
# Processor's `on handle` fires with payload
The handler name is a string literal. The type checker validates it when the literal is plain (no interpolation). Both forms are accepted; prefer the symbol form for new code — handler renames update the symbol reference automatically.
send
send(target, data, event: "...") posts a data event with explicit routing:
send(Processor, payload, event: "process")
# Processor's `on process` fires with payload
Both are non-blocking. Choose delegate when you want compile-time handler
validation; use send when you need the event: label to be determined
dynamically at runtime.
Direct cross-agent calls such as Worker.process(...) are not part of the
agent model. Inside an agent, call agent-owned helpers as self.task(...).
Across agents, use mailbox APIs so delivery remains explicit and asynchronous.
send vs ai.*
These are two completely separate communication paths.
flowchart LR
code["Agent code"]
code -->|"send(B, data)"| b["Agent B\n→ on <event> handler"]
code -->|"ai.classify / ai.prompt / ..."| llm["LLM\n→ returns a value"]
send is agent-to-agent messaging — no LLM involved.
ai.* calls send a prompt to the LLM and return its response.
Example: Bi-directional Communication
use std/ai
use std/io
agent Manager {
@tools [io]
state { done: int = 0 }
@on_start {
send(Worker, {id: 1}, event: "process")
}
on result(summary: str) {
io.show("Result received: {summary}")
self.done = self.done + 1
stop(Manager)
}
}
agent Worker {
@tools [ai]
on process(work: dynamic) {
output = ai.summarize(work, in: 1, unit: sentences)
send(Manager, output, event: "result")
}
}
run(Manager)
run(Worker)
sequenceDiagram
participant M as Manager
participant W as Worker
participant LLM as LLM (Ollama)
M->>W: send(event: "process", data: {id: 1})
W->>LLM: ai.summarize(...)
LLM-->>W: summary text
W->>M: send(event: "result", data: summary)
note over M: on result — prints summary, stops
Broadcasting to a team
broadcast(team, data, event: "...") fans out a single event to
every live agent whose @team [...] attribute contains the target team
name. Agents on other teams stay silent.
use std/io
agent Alpha {
@tools [io]
@team ["frontline"]
on alert(msg: str) { io.show("Alpha got {msg}") }
}
agent Beta {
@tools [io]
@team ["frontline"]
on alert(msg: str) { io.show("Beta got {msg}") }
}
agent Gamma {
@tools [io]
@team ["backoffice"]
on alert(msg: str) { io.show("Gamma got {msg}") }
}
agent Coordinator {
@on_start {
run(Alpha); run(Beta); run(Gamma)
broadcast("frontline", "production-down", event: "alert")
# Alpha and Beta fire; Gamma does not.
}
}
@team accepts a list, so an agent can belong to multiple teams. The
broadcast is non-blocking — every recipient handles the event on its
own mailbox in its own time.
Backpressure: RuntimeBusy
The interpreter event queue is bounded (default 1024; tunable via KEEL_EVENT_QUEUE_CAPACITY).
If the queue is full when send, delegate, or broadcast is called,
the call raises a RuntimeBusy error instead of blocking.
Catch it to apply your own backpressure strategy:
try {
send(Worker, payload)
} catch e: RuntimeBusy {
io.show("queue full — payload dropped")
}
A full queue typically means the event loop is processing a slow handler (e.g. an LLM call) while producers are sending faster than the loop can drain. Strategies:
- Catch and drop (log the miss)
- Catch and retry after a delay (
schedule.after(100.ms, ...)) - Reduce the send rate on the producer side
KEEL_EVENT_QUEUE_CAPACITY=<n>overrides the 1024 default. Use a small value (e.g. 2) in integration tests to deliberately triggerRuntimeBusywithout flooding the queue.
Key Properties
| Property | Behaviour |
|---|---|
| Routing | event: string in send matches the name in on <event> |
| Default event | Omitting event: routes to on message |
| No match | Unhandled events are silently dropped |
| Send | Non-blocking — sender continues immediately |
| Queue full | RuntimeBusy error raised — catch to handle backpressure |
| Execution | Handlers run one at a time — no race conditions on self. |
| Scope | In-process only — no network, no serialization |