7.6 KiB
Research: Teammate Message Delivery Approaches
Comparison of 3 Approaches
| Criterion | Inbox files | Agent SDK | CLI subprocess |
|---|---|---|---|
| Speed | ~5ms | ~12s | 10-15s |
| Cost | $0 | $0.01-0.08/msg | tokens |
| Works with running teammates | YES | NO | NO |
| Interrupts mid-turn | NO | NO | NO |
| Requires API key | NO | YES | NO |
| Memory usage | 0 | 0 | 100-320MB |
1. Inbox Files (Chosen)
How It Works
The app writes JSON directly to ~/.claude/teams/{team}/inboxes/{member}.json. Claude Code watches these files through fs.watch and delivers messages to agents between turns.
Pros
- Instant write (~5ms)
- $0 - no API calls
- Only way to communicate with already-running teammates
- Works with idle and active agents, although delivery still happens between turns
Cons
- Race condition during concurrent writes (see research-inbox.md)
- Undocumented format (internal API)
- Delivery happens between turns, not in real time
Message Format
{
"from": "user",
"text": "Do not touch auth.ts, I will change it myself",
"timestamp": "2026-02-17T15:30:00.000Z",
"read": false,
"summary": "Do not modify auth.ts",
"messageId": "uuid-for-retry-check"
}
2. Agent SDK (Rejected)
How It Works
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
const response = await client.messages.create({
model: 'claude-opus-4-7',
messages: [{ role: 'user', content: 'Send message to teammate...' }],
tools: [/* SendMessage, TaskUpdate, etc. */],
});
Why It Was Rejected
- Creates a new session - does not attach to a running teammate. SendMessage and TaskCreate are model tools, not programmatic calls.
- ~12 seconds per call because of the full API round trip.
- Costs tokens - $0.01-0.08 per message.
- Requires an API key - separate billing, not a Claude subscription.
When It May Be Useful
- Creating new teams programmatically.
- Workflow automation outside the real-time UI path.
3. CLI Subprocess (Rejected)
How It Works
claude --message "Send message to teammate-1: stop working on X"
Why It Was Rejected
- New process - does not inject into a running teammate.
- 10-15 second cold start.
- 100-320MB of memory per process.
- Each call costs tokens.
Delivery Architecture (Updated 2026-03-23)
Two Different Mechanisms: Lead vs Teammates
Lead reads ONLY stdin (stream-json). Messages to the lead are delivered with relayLeadInboxMessages(), which converts inbox entries into stream-json on stdin. Without relay, the lead does not see inbox messages.
Teammates are fully independent Claude Code processes. Each teammate watches its own inbox file through fs.watch and reads messages directly. Relay through the lead is not needed.
Message Flow: User -> Teammate
User -> [UI] -> TeamInboxWriter -> inboxes/{member}.json (read: false)
|
Teammate CLI (fs.watch) -> reads -> handles
|
Teammate -> inboxes/user.json (response)
|
[UI] <- TeamInboxReader <- reads user.json
The lead is not part of this path. The message is delivered directly.
Message Flow: User -> Lead
User -> [UI] -> stdin (stream-json) -> Lead CLI
|
Lead -> sentMessages.json / liveLeadProcessMessages
|
[UI] <- reads and renders
For the lead, relayLeadInboxMessages() additionally runs when inboxes/{lead}.json changes.
Teammate Responses
A teammate responds to the user through SendMessage(to="user"), which writes to inboxes/user.json. The UI reads this file through TeamInboxReader.getMessages(), which reads all inbox files in the directory.
Messages in user.json may not contain messageId; TeamInboxReader generates a deterministic ID from sha256(from + timestamp + text).
from: "user" Is Confirmed To Work
from: "user" works correctly, confirmed empirically on 2026-03-23:
- Teammate receives the message.
- Teammate correctly identifies that it came from the user.
- Teammate responds in
inboxes/user.json. - Fallback to
from: "team-lead"is not needed.
Why Relay Through the Lead Was Disabled (2026-03-23)
Previously, when sending a DM to a teammate, the app called relayMemberInboxMessages() in addition to writing to the inbox. This instructed the lead to forward the message through SendMessage(to=member). It caused 3 bugs:
- Lead replied instead of the teammate - the LLM interpreted the relay instruction as addressed to itself and answered the user directly.
- Duplicate messages -
markInboxMessagesRead()wrote to the file, triggering FileWatcher, which re-ran relay and created a loop. - Teammate did not reply to the user - the relay prompt contained "Do NOT send to user", which the teammate also saw through the lead.
Relay is disabled in teams.ts (handleSendMessage) and index.ts (FileWatcher). The code is commented out, not deleted. Lead relay (relayLeadInboxMessages) is unaffected.
Delivery: Timing and Constraints
Teammate Turn Cycle
Turn N:
1. Reads inbox -> sees new messages with read: false
2. Handles messages/tasks
3. Calls tools
4. Reasoning
5. Output
-> idle_notification -> IDLE
... wait ...
Turn N+1:
1. Wake-up (new inbox message / assigned task)
2. Reads inbox -> sees new messages
...
Delay
- Idle agent: receives the message on the next wake-up, usually a fraction of a second if inbox-change triggers.
- Active agent (mid-turn): receives the message only after the current turn completes, usually 1-30 seconds.
No Mid-Turn Interrupt
If an agent has already called Edit/Bash, the tool will complete. Our message arrives after that.
Example:
17:12:30 - Agent starts Edit on auth.ts
17:12:31 - We send "Do not touch auth.ts"
17:12:32 - Agent completes Edit (auth.ts changed)
17:12:33 - Agent reads inbox and sees our message
-> Too late, the file was already changed
Hard Interrupt (Future)
Possible approaches:
- kill -SIGINT the teammate process: hard interrupt, context loss.
- File flag
.interrupt-{member}: needs Claude Code support. - Anthropic API: if it becomes available.
Current decision: the delay is acceptable; hard interrupt is future work.
Final Decision
messageId Is Required In Every Outgoing Message
Every outgoing message includes messageId: crypto.randomUUID():
{
"from": "user",
"text": "Please review task #12",
"timestamp": "2026-02-17T15:30:00.000Z",
"read": false,
"summary": "Review request for task #12",
"messageId": "550e8400-e29b-41d4-a716-446655440000"
}
Verify Immediately After Write
- After atomic write, read the inbox and look for our
messageId. - If missing, message loss was detected -> show a warning in the UI instead of failing silently.
- No automatic retry in MVP.
3 States For Offline Members
| State | Condition | Display |
|---|---|---|
ACTIVE |
idle < 5 minutes | Green dot |
IDLE |
idle > 5 minutes | Yellow dot |
TERMINATED |
Received shutdown_response with approve: true |
Gray dot, "Terminated" |
State is determined from the timestamp of the latest event in the inbox (idle_notification or any message). TERMINATED is based only on an explicit shutdown_response.