| name | mailbox-interactive |
| description | Interactive waiting-task and mailbox workflow. |
| origin | pi-crew |
| triggers | ["respond to worker","nudge agent","mailbox message","supervisor contact","waiting task"] |
mailbox-interactive
Use this skill for live coordination between leader and workers. Mailbox provides an asynchronous message protocol for steer, follow-up, respond, and nudge operations.
Mailbox Architecture
Worker (waiting) ← mailbox inbox ← Leader (respond)
Worker (running) ← mailbox follow-ups ← Leader (followUp)
Leader → Worker: steer, followUp, nudge (non-blocking)
Worker → Leader: supervisor contact (blocking decision)
Mailbox file structure
Each run has a mailbox directory at .crew/state/runs/<runId>/mailbox/:
inbox.jsonl — incoming messages (to worker)
outbox.jsonl — sent messages (from worker)
steering.jsonl — steer messages specifically
Message structure
interface MailboxMessage {
id: string;
direction: "inbox" | "outbox";
from: string;
to: string;
body: string;
status: "pending" | "delivered" | "acknowledged" | "rejected";
priority: "low" | "normal" | "high";
sentAt: string;
deliveredAt?: string;
data?: Record<string, unknown>;
}
Core Operations
1. respond — Leader responds to waiting worker
async function respond(runId: string, taskId: string, body: string, priority = "normal") {
const message = appendInboxMessage(manifest, { taskId, body, priority });
const { tasks } = loadRunManifestById(cwd, runId);
const task = tasks.find(t => t.id === taskId);
if (task.status !== "waiting") {
throw new Error(`Cannot respond to non-waiting task: ${task.status}`);
}
const updated = { ...task, status: "running", waitingSince: undefined };
saveRunTasks(manifest, [updated]);
appendEvent(eventsPath, { type: "task.responded", taskId, message: body });
return message;
}
2. steer — Live agent steering (non-blocking)
async function steerLiveAgent(agentId: string, message: string) {
const handle = getLiveAgent(agentId);
if (!handle) throw new Error(`Live agent '${agentId}' not found`);
if (typeof handle.session.steer === "function") {
await handle.session.steer(message);
handle.updatedAt = new Date().toISOString();
return handle;
}
handle.pendingSteers.push(message);
return handle;
}
3. followUp — Non-blocking follow-up to running agent
async function followUpLiveAgent(agentId: string, prompt: string) {
const handle = getLiveAgent(agentId);
if (!handle) throw new Error(`Live agent '${agentId}' not found`);
if (typeof handle.session.prompt === "function") {
await handle.session.prompt(prompt, { source: "api", expandPromptTemplates: false });
handle.updatedAt = new Date().toISOString();
return handle;
}
handle.pendingFollowUps.push(prompt);
return handle;
}
4. nudge — Ask agent to report status
function nudgeAgent(manifest: TeamRunManifest, agentId: string, message?: string) {
const agent = readCrewAgents(manifest).find(a => a.id === agentId || a.taskId === agentId);
if (!agent) throw new Error(`Agent '${agentId}' not found`);
const text = message ?? "Please report your current status, blocker, or smallest next step.";
const mailboxMessage = appendSteeringMessage(manifest, {
taskId: agent.taskId,
body: text,
priority: "normal",
data: { source: "nudge-agent" }
});
appendEvent(manifest.eventsPath, {
type: "agent.nudged",
runId: manifest.runId,
taskId: agent.taskId,
message: text,
data: { agentId: agent.id, mailboxMessageId: mailboxMessage.id }
});
return mailboxMessage;
}
Supervisor Contact
Workers can contact the leader for blocking decisions:
function parseSupervisorContact(line: string): SupervisorContact | null {
const match = line.match(/@supervisor\s+(\w+):\s*(.*)/);
if (!match) return null;
return { type: match[1], prompt: match[2] };
}
appendEvent(eventsPath, {
type: "supervisor.contact",
runId: manifest.runId,
taskId: task.id,
message: contact.prompt,
data: { contactType: contact.type }
});
Contact types:
need_confirmation — worker needs explicit approval
need_input — worker needs a text response
need_selection — worker needs to choose from options
Queue Depth and Backpressure
Mailbox queues can grow large with pending messages:
const MAX_PENDING_STEERS = 50;
const MAX_PENDING_FOLLOWUPS = 50;
if (handle.pendingSteers.length >= MAX_PENDING_STEERS) {
handle.pendingSteers.shift();
}
Backpressure signals:
- If
pendingSteers.length > 20, warn in UI
- If
pendingFollowUps.length > 20, warn in UI
Corrupt JSONL Handling
Mailbox JSONL files can become corrupt on crash. Handle gracefully:
function readMailboxMessages(path: string): MailboxMessage[] {
if (!fs.existsSync(path)) return [];
const lines = fs.readFileSync(path, "utf-8").split("\n");
const messages: MailboxMessage[] = [];
for (const line of lines) {
if (!line.trim()) continue;
try {
messages.push(JSON.parse(line) as MailboxMessage);
} catch {
continue;
}
}
return messages;
}
Timeout Patterns
Responding with timeout
async function respondWithTimeout(
runId: string,
taskId: string,
body: string,
timeoutMs = 30_000
): Promise<MailboxMessage> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const message = await respond(runId, taskId, body);
clearTimeout(timeout);
return message;
} catch (error) {
clearTimeout(timeout);
if (error.name === "AbortError") {
throw new Error(`Respond timed out after ${timeoutMs}ms`);
}
throw error;
}
}
Non-blocking follow-up (no wait)
function followUpAsync(agentId: string, prompt: string): void {
const handle = getLiveAgent(agentId);
if (!handle) return;
if (typeof handle.session.prompt === "function") {
void handle.session.prompt(prompt, { source: "api", expandPromptTemplates: false })
.catch(() => { });
} else {
handle.pendingFollowUps.push(prompt);
}
}
Run Ownership Enforcement
Foreign sessions cannot mutate a run's mailbox:
function verifyRunOwnership(manifest: TeamRunManifest, sessionId: string, force = false) {
if (!force && manifest.ownerSessionId && manifest.ownerSessionId !== sessionId) {
throw new Error(
`Run ${manifest.runId} is owned by session ${manifest.ownerSessionId}. ` +
`Cannot mutate from session ${sessionId}. Use force=true to override.`
);
}
}
Enforcement — Mailbox Interactive Gate
Before responding to or mutating mailbox state, verify:
If ANY answer is NO → Stop. Verify mailbox state before mutating.
Anti-patterns
- Resuming non-waiting tasks:
respond only works on waiting tasks. Resuming running tasks corrupts state.
- Injecting mailbox messages into foreign runs: Always verify
ownerSessionId before mutating.
- Treating every progress update as blocking: Use
followUp (non-blocking) instead of respond for status updates.
- Reading large mailbox files in hot paths: Cache mailbox counts; don't read JSONL on every render tick.
- Not handling corrupt JSONL: Skip malformed lines; don't fail the whole read.
- Losing pending messages on session switch: Pending steers/followups are stored in-memory in the handle. They survive session fork but not session death.
Source patterns
src/state/mailbox.ts — appendInboxMessage, appendSteeringMessage, readMailboxMessages
src/extension/team-tool/respond.ts — respond tool handler, ownership verification
src/extension/team-tool/cancel.ts — cancel with mailbox cleanup
src/extension/team-tool/api.ts — steer-agent, follow-up-agent, nudge-agent
src/runtime/live-agent-manager.ts — pendingSteers, pendingFollowUps, steerLiveAgent, followUpLiveAgent
src/runtime/supervisor-contact.ts — parseSupervisorContactFromLine, recordSupervisorContact
src/ui/overlays/mailbox-detail-overlay.ts — mailbox UI
Verification
cd pi-crew
ls .crew/state/runs/<runId>/mailbox/ 2>/dev/null || echo "No mailbox yet"
node --experimental-strip-types --test test/unit/respond-tool.test.ts
node --experimental-strip-types --test test/unit/mailbox-detail-overlay.test.ts
npx tsc --noEmit
npm test