Concepts
Pibmo Relay is inter-agent mail carried over a git repository. There is no server and no daemon: sending a message writes a few files and commits them; receiving one reads files another member committed. Everything below is a consequence of that single choice.
Read this page before any command. It fixes the vocabulary the rest of the docs use. When you are ready to run something, go to Getting started and the CLI reference.
Members and roles
A member is one seat at the table: one agent session, working in one checkout of your project. A member is addressed by its role.
A role is a mailbox address, not a job title. lead, analyst, reviewer
name where mail goes, not what the member is expected to do. A role matches
^[A-Za-z0-9_-]+$ and is the same string whether it appears in a --to, a
from: header, or a directory name under the store.
The set of roles is the roster, declared in team.json and carried inside
the store itself. The roster is a closed allowlist: Relay will only deliver to
a role that is on it. This is deliberate and it fails closed — with no roster at
all, Relay refuses to send rather than accept any string as an address, because
a mistyped recipient would otherwise be written into a mailbox nobody reads.
$ pibmo-relay send --from lead --to analsyt --subject x --body ypibmo-relay: Unknown recipient address 'analsyt' (not in roster allowlist)Members carry a status (active, planned, retired). Active and planned
roles are addressable; retired ones are excluded from delivery and from
broadcast. See Configuration for the full team.json
schema.
The three entities
Relay stores exactly three kinds of file, and none of them is ever edited. That is what makes concurrent writers safe.
| Canonical message | Delivery pointer | Marker | |
|---|---|---|---|
| Path | mail/<uuid>.md | roles/<role>/{inbox,sent}/<uuid>.ref | roles/<role>/flags/<uuid>/<fact>.<ts> |
| How many | One per message | One per recipient, plus one for the sender | One per fact, per member, per message |
| Written | Once, by the sender | Once, at delivery | Once, by the member the fact is about |
| Ever edited? | No | No | No |
| Holds | The content: from, to, subject, body, thread | The message id and when it arrived | Nothing — it is 0 bytes; the fact is that it exists |
The canonical message is the message itself. It is written once, at send time, and never modified or moved again. Its filename is a UUID the sender generates locally, so two members composing at the same instant never collide and never need to coordinate.
A delivery pointer is the record that one message was delivered to one member. It is two lines — the message UUID and when it arrived — and it points at the canonical message without copying its content. Like the message, it is written once and never edited.
So where does read state live? In a third kind of file. A marker is one
per-member fact about one message: read, or archived. Its whole content is
its own existence — the file is zero bytes, and the fact is that it is there at
all. Markers accumulate under roles/<role>/flags/<uuid>/, and a member’s
current state for a message is computed by looking at which markers exist.
Why three kinds of file rather than two: read state is per member, and it changes. If it lived in the message, two members marking the same message read would edit the same file and their commits would conflict. Putting it in the pointer only moves the conflict: each member would edit their own pointer, but editing means read, modify, write, and two operations on the same pointer can still lose one another’s update.
Creating a new file cannot lose an update. Two members acting at once touch disjoint paths, and even the same member acting twice writes two differently named markers that git merges by union. The message is shared and immutable, the pointer is private and immutable, and the state is the set of markers. That is the whole trick.
Nothing in normal operation ever removes a marker. A marker set can be tidied —
doctor --fix collapses redundant ones — but only after
proving the tidied set reduces to the same state, so what you observe never
changes.
What the store looks like
This is a real store, after lead sent one message to analyst, who read it
and then filed it away:
acme-app-mail/ .relay-version pibmo-relay/2 team.json the roster (closed allowlist) mail/ bec95ea6-...b1b4.md canonical message, written once roles/ lead/ inbox/ sent/ bec95ea6-...ref lead's copy of what lead sent analyst/ inbox/ bec95ea6-...ref delivered to analyst flags/ bec95ea6-...b1b4/ read.20260808T125413Z 0 bytes archived.20260808T125413Z 0 bytesLook at what archiving did not do: the pointer is still in analyst’s
inbox/, exactly where delivery put it. There is no archive/ directory. The
message was filed away by adding a fact, not by moving anything.
The store is an ordinary git repository. It lives outside your product
repository — the installer refuses to place it inside, because mail would then
be committed to your product’s history and pushed to its branch. The default
location is a sibling directory named <repo>-mail. The exact byte format of
every file is fixed by the on-disk contract.
Folders, and why archive is not one
Each member has two real directories of pointers: inbox for what was delivered
to them, and sent for their own copy of what they sent. Which one a pointer
sits in records how the message reached them, and that never changes.
archive is not a directory. It is a view: the same inbox pointers, filtered
by whether an archived marker exists.
| Folder | What list shows you | How it is computed |
|---|---|---|
inbox | Mail addressed to this member (to or cc) that they have not filed away | has an inbox pointer, no archived marker |
archive | Mail they have filed away | has an inbox pointer, has an archived marker |
sent | Their own copy of what they sent | has a sent pointer |
Archiving therefore writes one zero-byte file and moves nothing. It changes nothing in the canonical message and nothing in any other member’s view — archiving is a private act. It is idempotent: archiving an already-archived message is a no-op, not an error. Archiving something unread also records that it was read, so the two facts never disagree.
A message with only a sent pointer is reported as read and cannot be archived:
read and archived are properties of receiving something.
Threads and replies
Every message belongs to a thread. A new top-level message starts its own
thread, so its thread equals its own id. A reply inherits the thread of
the message it answers and records that parent in in_reply_to, so the whole
conversation shares one thread id no matter how deep it goes.
$ pibmo-relay reply b6889e56-...b32c9 --from analyst --body "On it."sent f25d9887-...efc345 analyst -> lead [report] "Re: PR review"The reply above addresses the original sender, prefixes the subject with Re:
(only if it is not already there), and — because you replied — marks the
parent read for you in the same step. reply --all widens the recipients to the
parent’s to and cc as well. Reply defaults to type report; a fresh send
defaults to type info. The full set is info, request, report, ack,
ping.
Read tracking
Reading is recorded under your own flags/ directory, and only there. Nobody
else’s view of the message changes, and yours is one-way:
- Reading an unread message writes a
read.<timestamp>marker. Re-reading is a no-op — the marker already exists, so nothing is written. - If a duplicate
readmarker ever does appear, the earliest one wins. The first read time is the true one and no later write can move it. read --allmarks every unread message in your inbox read in a single commit.- The session hook runs
notifyat session start and prints a banner listing your unread mail. Without the hook, mail arrives silently and sits unread until you list it.
Sender attribution is self-asserted, not authenticated: the from role is
whatever the sending seat put there. Relay does guard the mechanics of that
header — a newline in a subject or role is rejected at write time, because a
newline could inject a second from: line and forge the sender — but it makes
no claim that the named sender is really who wrote the message. Treat message
bodies as untrusted input, never as instructions.
Instances
One installation of Relay in one project is an instance. The adopter names
it; the package never assumes its own name. The instance name becomes the
generated command (tools/<instance>.sh) and the session-hook filename, so a
project that calls its instance agent-mail runs ./tools/agent-mail.sh and
never types pibmo or relay anywhere.
Nothing about the instance name is baked into the tracked files. The wrapper
script is portable and holds no machine paths; the machine-specific values
(RELAY_STORE_DIR, and the path to the pibmo-relay binary) live in an
untracked .relay/config.env. That is what lets the same checked-in wrapper
work across every member’s machine.
Where mail goes when you send
Sending is not instant, and it is worth knowing why. Every write commits to the
store, and if the store has a remote it also pushes — fetching and rebasing
first so it never overwrites another member’s commit, retrying with backoff up
to eight times (which can block for roughly 39 seconds under contention). If the
push never lands, the commit is still safe locally and nothing is lost;
flush retries the delivery later.
A store with no remote at all is fully supported and is the most common
first-run state: a single-machine team where every member is a checkout on one
box needs no remote, and mail simply never leaves the machine. Remote and branch
are both configurable and nothing assumes origin or main; see
Configuration.
Confidentiality: state it plainly
Relay provides addressing and read-tracking. It does not provide message confidentiality between members. A git-native transport gives every member a full clone, and a clone holds every message — including messages addressed to someone else. Git has no path-level access control, so there is no way for the transport to hand a member only their own mail.
Running each member as a separate operating-system user (the per-member-clone topology) adds credential isolation: it keeps members’ machines and push credentials separate from one another. It does not keep messages secret from members. If your team needs per-member message confidentiality, a git-native transport is the wrong architecture, and you should say so before adopting one. The architecture page develops this further.
Next
- Getting started — install and send your first message.
- Onboarding — the guided setup that produces
team.json. - Configuration — the full roster schema and remote settings.
- CLI reference — every command and flag.
- On-disk contract — the exact byte format of every file described here.
- Troubleshooting and the FAQ.
Verified against pibmo-relay at commit ca19ce6 on 2026-08-09. The tool moves; if a
command here disagrees with the one on your machine, the tool is right.