# How SwarmForge's Handoff Daemon (handoffd) Manages Inter-Agent Communication

> Discover how SwarmForge's Handoff Daemon handoffd manages inter-agent communication using a file-based message broker for efficient routing and real-time alerting.

- Repository: [Robert C. Martin/swarm-forge](https://github.com/unclebob/swarm-forge)
- Tags: internals
- Published: 2026-09-01

---

**SwarmForge's Handoff Daemon (`handoffd`) orchestrates inter-agent communication through a lightweight, file-based message broker that scans outbox directories, routes handoff messages to recipient inboxes, synchronizes the shared task board, and triggers tmux notifications for real-time agent alerting.**

Inter-agent communication is the backbone of any multi-agent system. In **SwarmForge**, unclebob's open-source framework for AI agent collaboration, this responsibility falls to `handoffd`—a Bash-compatible Clojure daemon implemented in `swarmforge/scripts/handoffd.bb`. Unlike complex message queues, `handoffd` uses a simple filesystem protocol that keeps agents loosely coupled while guaranteeing reliable delivery.

---

## Architecture Overview: Filesystem-Based Message Routing

The daemon follows a **polling model** with two operational modes:

- **Continuous mode** (default): Runs an infinite loop with 1-second sleep intervals between polls
- **One-off mode** (`--once`): Executes a single poll cycle and exits—ideal for debugging or CI pipelines

Both modes converge on the `poll-once!` function, which implements the core routing logic.

---

## Startup and Role Configuration

### Daemon Initialization

When `handoffd` starts, the `configure!` function (lines 33-45 in `handoffd.bb`) establishes the runtime environment:

```clojure
(defn configure! …)

```

This builds paths to critical directories and files:

- **State directory**: `.swarmforge/` (project configuration)
- **Daemon directory**: `.swarmforge/handoffd/` (PID file, stop signals, logs)
- **Roles file**: `swarmforge/.swarmforge/roles.tsv` (agent definitions)
- **Tmux socket**: For sending notifications to agent sessions

The daemon also records its operational mode—whether to run continuously or execute a single poll (`once?`).

### Loading Agent Roles

Agent capabilities and routing information come from `load-roles` (lines 63-75):

```clojure
(defn load-roles [] …)

```

Each line in `roles.tsv` parses into a map keyed by **role name**, containing:

- `work-tree`: The agent's working directory path
- `tmux-session`: Target session for notifications
- `handoff-type`: How this role receives handoffs (default: `"task"`)

This role map drives all subsequent routing decisions, from inbox/outbox path resolution to tmux session targeting.

---

## The Main Polling Loop: poll-once!

The `poll-once!` function (lines 400-417) executes four sequential operations on every iteration:

```clojure
(defn poll-once! [] …)

```

1. **Reload roles** via `load-roles`—enabling dynamic reconfiguration without restart
2. **Read tmux socket path** for notification delivery
3. **Collect outbox files** across all roles using `outbox-files`
4. **Process each file** through `process-outbox-file!`

This design ensures fresh state on every poll, accommodating role changes and filesystem events without complex locking.

---

## Message Processing: Hold, Deliver, and Notify

### File Processing Pipeline

`process-outbox-file!` (lines 92-99) handles each `.handoff` file:

```clojure
(defn process-outbox-file! [roles socket path] …)

```

The pipeline has three stages:

1. **Parse headers** via `parse-message` to extract routing metadata
2. **Evaluate hold criteria** via `should-hold?`
3. **Route to hold or delivery** (`hold!` or `deliver!`)

### Hold Logic: Approval Workflows

The `should-hold?` function (lines 92-98) implements a specific governance rule:

```clojure
(defn should-hold? [roles headers] …)

```

A handoff is **held** when **all** conditions match:

- Type is `git_handoff`
- Origin is the `master` role (specifier/approver pattern)
- Single recipient (not broadcast)
- Lacks an `approved` header

Held files move to `handoffs/pending_approval/` until manually approved.

### Delivery Logic: End-to-End Routing

The `deliver!` function (lines 50-73) executes four coordinated actions:

```clojure
(defn deliver! [roles socket sender-role path] …)

```

**1. Board Synchronization**

`update-board!` rewrites the shared task board using `pack-board!`. The board operation depends on handoff type and recipients—common operations include `move`, `done`, and `archive`.

**2. Per-Recipient Delivery**

For each `to` header in the message:

- Look up recipient role: `(get roles recipient)`
- Copy handoff to inbox: `target-path` resolves to `.swarmforge/handoffs/inbox/new/`
- Add delivery metadata: `add-delivery-headers` timestamps and traces the route
- Trigger notification: `notify!`

**3. Tmux Notification**

The `notify!` function (lines 13-24) sends a wake-up signal:

```clojure
(defn notify! [socket session] …)

```

This executes `tmux send-keys` with:

- A configurable wake message: `"You have new handoff mail …"`
- Carriage return and line-feed to ensure prompt visibility

**4. Post-Delivery Housekeeping**

- Move original outbox file to sender's `sent-dir`
- Archive sender's board entry via `archive-sender!`
- Wake blocked senders if approval unblocked a workflow: `maybe-notify-unblocked-sender!`

---

## Graceful Shutdown Mechanisms

The daemon monitors two stop signals through `should-stop?`:

- **External stop file**: `handoffd/stop` (filesystem trigger)
- **Internal flag**: `stopping-flag` (set by JVM shutdown hook)

When either signal is detected, `poll-once!` returns false, breaking the main loop. The daemon then:

1. Removes the PID file
2. Writes final log entry
3. Exits cleanly

This dual-channel design allows both programmatic control (touching the stop file) and OS-level termination handling.

---

## Handoff Protocol Format

All messages follow the **header-body format** defined in [`handoff-protocol.md`](https://github.com/unclebob/swarm-forge/blob/main/handoff-protocol.md):

```text
id: 12345
from: developer
to: build
type: git_handoff
task_id: 42
priority: high

Fix broken test suite

```

**Standard headers:**

| Header | Purpose |
|--------|---------|
| `id` | Unique message identifier |
| `from` | Originating role |
| `to` | Target recipient(s), space-separated for multicast |
| `type` | Handoff category (`git_handoff`, `task`, etc.) |
| `task_id` | Reference to board item |
| `priority` | Scheduling hint |
| `approved` | Presence indicates governance clearance |

The daemon's `parse-message` and `render-message` functions preserve header ordering for well-known fields while permitting arbitrary extension headers.

---

## Practical Usage Examples

### Start the Daemon (Production)

```bash

# From project root

./swarmforge/scripts/handoffd.bb /path/to/project

```

Runs continuously, polling every 1000ms and routing handoffs as agents produce them.

### Debug with Single Poll

```bash
./swarmforge/scripts/handoffd.bb --once /path/to/project

```

Useful for CI validation or troubleshooting routing logic without daemon persistence.

### Manual Handoff Creation (Testing)

```bash
cat > .swarmforge/handoffs/outbox/myagent.myrole/12345.handoff <<EOF
id: 12345
from: developer
to: build
type: git_handoff
task_id: 42
priority: high

Fix broken test suite
EOF

```

The daemon will detect this file, move it to `build`'s inbox, update the board, and notify the `build` tmux session.

### Force Approval Hold

```bash
cat > .swarmforge/handoffs/outbox/master.specifier/99999.handoff <<EOF
id: 99999
from: master
to: deploy
type: git_handoff
task_id: 100

Production deployment request
EOF

# Note: no 'approved' header

```

This lands in `pending_approval/` until edited to include `approved: true`.

---

## Key Implementation Files

| File | Responsibility |
|------|---------------|
| `swarmforge/scripts/handoffd.bb` | Core daemon: configuration, polling loop, delivery orchestration |
| `swarmforge/scripts/handoff_lib.bb` | Shared utilities for handoff parsing and manipulation |
| [`swarmforge/handoff-protocol.md`](https://github.com/unclebob/swarm-forge/blob/main/swarmforge/handoff-protocol.md) | Formal specification of message format and semantics |
| [`swarmforge/scripts/pack_board.sh`](https://github.com/unclebob/swarm-forge/blob/main/swarmforge/scripts/pack_board.sh) | Board manipulation CLI invoked by `update-board!` |
| [`swarmforge/scripts/ready_for_next_task.sh`](https://github.com/unclebob/swarm-forge/blob/main/swarmforge/scripts/ready_for_next_task.sh) | Agent-side inbox processor for task initiation |
| `swarmforge/constitution/articles/handoffs.prompt` | AI agent instructions for handoff creation |

---

## Summary

SwarmForge's inter-agent communication relies on `handoffd`'s elegant filesystem-based architecture:

- **Continuous polling** of role outboxes with 1-second granularity
- **Dynamic role loading** from `roles.tsv` enables hot reconfiguration
- **Approval workflow support** via hold logic for `git_handoff` from master
- **Atomic file operations** guarantee delivery despite crashes
- **Tmux integration** provides real-time agent notification without network dependencies
- **Dual shutdown channels** support both graceful and forced termination

This design prioritizes **simplicity, inspectability, and operational reliability** over throughput—appropriate for human-in-the-loop agent workflows where visibility trumps raw performance.

---

## Frequently Asked Questions

### What triggers a handoff to be held for approval?

A handoff enters `pending_approval/` when it is a `git_handoff` type originating from the `master` role, addressed to a single recipient, and missing an `approved` header. This three-condition check in `should-hold?` implements SwarmForge's specifier-approver governance pattern for sensitive operations like production deployments.

### How does handoffd notify agents of new messages?

The daemon invokes `notify!` after successfully copying a handoff to the recipient's inbox. This function executes `tmux send-keys` against the recipient's configured tmux session, writing a wake message followed by newline characters. Agents see `"You have new handoff mail …"` appear at their prompt without requiring network listeners or socket connections.

### Can handoffd run without tmux?

Yes, but notifications fail silently. The `notify!` function wraps tmux calls; if no session is configured or tmux is unavailable, delivery still completes (file copied, board updated) but the agent receives no real-time alert. The agent's next poll of their inbox—typically via [`ready_for_next_task.sh`](https://github.com/unclebob/swarm-forge/blob/main/ready_for_next_task.sh)—will discover the message.

### What happens if the daemon crashes during message delivery?

The atomic move operations in `deliver!` provide crash safety. A handoff is only removed from the outbox after successful inbox copy and board update. If interrupted, the outbox file remains and will be reprocessed on daemon restart. Duplicate delivery is prevented through idempotent file moves and board operations keyed by `id` headers.