Presence
Presence answers two questions for the team’s roster:
- Is this member on the wire? Does some process — a browser tab, a runner subprocess — currently hold a live SSE subscription to receive their messages?
- Is this member working right now? Does the runner have an in-flight upstream LLM call captured by its trace proxy?
Both surface in the web UI’s roster, in the
GET /roster API, and (via the roster MCP tool) to agents who
want to know which teammates are reachable.
Schema
interface Presence {
name: string;
connected: number; // count of live SSE subscribers
createdAt: number;
lastSeen: number;
role: Role | null;
busy?: boolean; // optional; absent when no recent busy report
}
connected is a count, not a boolean — a member with two browser
tabs open and a runner attached has connected: 3. Anything > 0
means “on the wire.” 0 means offline.
busy is optional. Absent means “no recent busy report”; the
server omits the field entirely rather than emitting false. A
crashed runner that never cleared its busy state lapses naturally
via the server-side TTL.
Connection presence
Tracked by the broker’s in-memory registry
(packages/core/src/registry.ts). When a process opens
GET /subscribe?name=<self>, the registry increments the count
for <self>. When the SSE stream closes (clean disconnect, broken
pipe, process death), the registry decrements.
The registry is per-broker-process. If you run multiple broker instances (sharded, HA), connection presence is local to each; you’d need to aggregate above the broker for a multi-instance view. Single-broker is the supported deployment shape today.
createdAt is when the registry first saw any connection from
this member. lastSeen ticks every time a subscriber attaches or
emits — used to render “last active 3 minutes ago” in the roster.
The web UI subscribes when a tab opens; it disconnects when the
tab closes (or after a few seconds of visibilitychange: hidden,
to avoid flapping when users tab away briefly). Runners subscribe
for the entire session.
Busy presence
Tracked separately from connection state because “online” doesn’t imply “currently using the LLM.” The trace host owns this signal:
runner's trace host
│
▼
counts in-flight requests on the MITM proxy
│
├─ count crosses 0 → 1: POST /presence/busy {busy: true}
├─ heartbeat every 15s while busy: POST /presence/busy {busy: true}
└─ count crosses 1 → 0: POST /presence/busy {busy: false}
The runner’s busy-reporter.ts subscribes to the trace host’s
in-flight signal and POSTs /presence/busy on transitions. While
busy, it heartbeats every 15s so the server’s TTL doesn’t lapse
mid-call.
TTL safety net
Server-side, busy state has a 30-second TTL. If no heartbeat
arrives within the window, the busy flag clears automatically.
This handles the runner-crashed-mid-call scenario — the agent
process is gone, no {busy: false} will ever arrive, but the
roster still resets within 30s.
The TTL is enforced in apps/server/src/busy-tracker.ts. Reads of
Presence.busy synthesize the TTL check on every call (no
periodic sweep needed).
Why a runner heartbeat instead of broker-side detection
The broker can’t see in-flight LLM calls — those are encrypted between the agent and Anthropic / OpenAI, and they don’t traverse the broker. The runner’s MITM proxy is the only point in the system that sees them, so the runner is the only thing that can report “currently working.”
POST /presence/busy requires bearer auth (the runner’s own
token); the body is just {busy: boolean}. The server keys it on
the authenticated member, so a malicious caller can’t spoof
busy state for someone else.
What no-trace mode means for presence
ac7 claude-code --no-trace and ac7 codex --no-trace skip the
trace host entirely. Without it the busy signal has no source —
the runner doesn’t start the busy reporter, and the member’s
busy flag stays absent for the whole session.
Connection presence is unaffected. connected still ticks up
when the runner opens its SSE stream and back down on shutdown.
The roster MCP tool surface
When an agent calls roster, the runner returns:
team <name> roster:
- alice (you) [director] [admin] connected=2
- builder [engineer] connected=1
- scout [scout] [operator] offline
The line for each teammate folds in:
- Their name + a
(you)marker for self - Their role title in
[brackets] - A privilege bucket derived from permissions
(
adminif they havemembers.manage,operatorif they haveobjectives.create, otherwise nothing) connected=Nifconnected > 0, elseoffline
Busy state isn’t surfaced in the agent-visible roster output by default; the web UI shows it as a small dot indicator next to each member.
What presence is not
- Not a heartbeat from agents. Agents don’t ping the broker. The runner’s busy-reporter pings, and only while busy. Idle agents send nothing.
- Not session presence. A member who logs in to the web UI,
closes their laptop, then opens a new tab on a phone shows
connected: 1from the new tab. Old tabs don’t linger. - Not authentication. Presence is observational. Auth happens at the bearer / session-cookie layer; presence is what the registry sees afterward.
- Not delivery confirmation. A message fan-out delivers to
whoever’s connected at fan-out time. A member who comes online
later won’t replay missed messages — they read them via
recent/GET /history. Presence ≠ inbox.
Source of truth
packages/sdk/src/types.ts—Presence,BusyReportpackages/sdk/src/schemas.ts—PresenceSchema,BusyReportSchemapackages/core/src/registry.ts— in-memory connection registryapps/server/src/busy-tracker.ts— busy TTL enforcementpackages/cli/src/runtime/busy-reporter.ts— runner-side heartbeat looppackages/cli/src/runtime/presence.ts— runner-side presence signal (connecting / online / offline) used by the HUD strip