feat(CUST-WP-0015): implement agent inbox for inter-agent coordination

Adds a message-passing layer to state-hub so Claude instances can
coordinate across sessions without polling shared progress events.

- Migration f3a4b5c6d7e8: agent_messages table with thread support
- FastAPI router: POST/GET /messages/, thread view, mark-read, archive, reply
- 4 MCP tools: send_message, get_messages, mark_message_read, reply_to_message
- Observable dashboard: /inbox page with unread/read/archived sections + KPI
- CLAUDE.md updates: global, custodian, marki-docx, activity-core, template
- TOOLS.md: Agent Inbox tools section documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 02:55:45 +01:00
parent 07031fa63e
commit ac34c062df
13 changed files with 755 additions and 2 deletions

View File

@@ -88,7 +88,8 @@ Every Claude Code session in this repository must follow this ritual:
**On session start:**
1. Call `get_state_summary()` via the `state-hub` MCP tool for orientation
2. Note any blocking decisions or blocked tasks before starting work
2. Check the agent inbox: `get_messages(to_agent="hub", unread_only=True)` — mark read and act on any messages
3. Note any blocking decisions or blocked tasks before starting work
**On session close (before ending):**
1. Call `add_progress_event()` to log what was done, decided, or discovered

View File

@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from api.database import engine
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages
@asynccontextmanager
@@ -42,6 +42,7 @@ app.include_router(domain_goals.router)
app.include_router(repo_goals.router)
app.include_router(contributions.router)
app.include_router(sbom.router)
app.include_router(messages.router)
app.include_router(state.router)
app.include_router(policy.router)

View File

@@ -14,6 +14,7 @@ from api.models.technical_debt import TechnicalDebt, TDStatus
from api.models.contribution import Contribution, ContributionType, ContributionStatus
from api.models.sbom_snapshot import SBOMSnapshot
from api.models.sbom_entry import SBOMEntry, Ecosystem
from api.models.agent_message import AgentMessage
__all__ = [
"Base",
@@ -32,4 +33,5 @@ __all__ = [
"Contribution", "ContributionType", "ContributionStatus",
"SBOMSnapshot",
"SBOMEntry", "Ecosystem",
"AgentMessage",
]

View File

@@ -0,0 +1,44 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, new_uuid
class AgentMessage(Base):
__tablename__ = "agent_messages"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
from_agent: Mapped[str] = mapped_column(String(100), nullable=False)
to_agent: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
subject: Mapped[str] = mapped_column(String(500), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
thread_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agent_messages.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
read_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
archived_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=text("now()"),
nullable=False,
)
thread_root: Mapped["AgentMessage | None"] = relationship(
"AgentMessage",
remote_side="AgentMessage.id",
foreign_keys=[thread_id],
lazy="select",
)

View File

@@ -0,0 +1,138 @@
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.agent_message import AgentMessage
from api.schemas.agent_message import MessageCreate, MessageRead, MessageReply
router = APIRouter(prefix="/messages", tags=["messages"])
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
async def send_message(
body: MessageCreate,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
"""Send a message from one agent to another (or 'broadcast')."""
if body.thread_id:
root = await session.get(AgentMessage, body.thread_id)
if root is None:
raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found")
msg = AgentMessage(
from_agent=body.from_agent,
to_agent=body.to_agent,
subject=body.subject,
body=body.body,
thread_id=body.thread_id,
)
session.add(msg)
await session.commit()
await session.refresh(msg)
return msg
@router.get("/", response_model=list[MessageRead])
async def list_messages(
to_agent: str | None = None,
from_agent: str | None = None,
unread_only: bool = False,
limit: int = 50,
session: AsyncSession = Depends(get_session),
) -> list[AgentMessage]:
"""List messages. Filter by recipient, sender, or unread status."""
q = select(AgentMessage).where(AgentMessage.archived_at.is_(None))
if to_agent:
q = q.where(
(AgentMessage.to_agent == to_agent) | (AgentMessage.to_agent == "broadcast")
)
if from_agent:
q = q.where(AgentMessage.from_agent == from_agent)
if unread_only:
q = q.where(AgentMessage.read_at.is_(None))
q = q.order_by(AgentMessage.created_at.desc()).limit(limit)
result = await session.execute(q)
return list(result.scalars().all())
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
async def get_thread(
thread_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[AgentMessage]:
"""Get all messages in a thread (root + replies), oldest first."""
# Include the root message itself
q = select(AgentMessage).where(
(AgentMessage.id == thread_id) | (AgentMessage.thread_id == thread_id)
).order_by(AgentMessage.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.patch("/{message_id}/read", response_model=MessageRead)
async def mark_read(
message_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
"""Mark a message as read."""
msg = await _get_message(message_id, session)
if msg.read_at is None:
msg.read_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(msg)
return msg
@router.patch("/{message_id}/archive", response_model=MessageRead)
async def archive_message(
message_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
"""Archive a message (soft-delete)."""
msg = await _get_message(message_id, session)
msg.archived_at = datetime.now(timezone.utc)
if msg.read_at is None:
msg.read_at = msg.archived_at
await session.commit()
await session.refresh(msg)
return msg
@router.post("/{message_id}/reply", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
async def reply_to_message(
message_id: uuid.UUID,
body: MessageReply,
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
"""Reply to a message. Marks the original as read and creates a reply in the same thread."""
original = await _get_message(message_id, session)
# Mark original as read
if original.read_at is None:
original.read_at = datetime.now(timezone.utc)
# Thread root is either the original's thread_id or the original itself
thread_root = original.thread_id or original.id
reply = AgentMessage(
from_agent=body.from_agent,
to_agent=original.from_agent,
subject=f"Re: {original.subject}",
body=body.body,
thread_id=thread_root,
)
session.add(reply)
await session.commit()
await session.refresh(reply)
return reply
async def _get_message(message_id: uuid.UUID, session: AsyncSession) -> AgentMessage:
msg = await session.get(AgentMessage, message_id)
if msg is None:
raise HTTPException(status_code=404, detail=f"Message {message_id} not found")
return msg

View File

@@ -0,0 +1,30 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class MessageCreate(BaseModel):
from_agent: str
to_agent: str
subject: str
body: str
thread_id: uuid.UUID | None = None
class MessageReply(BaseModel):
from_agent: str
body: str
class MessageRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
from_agent: str
to_agent: str
subject: str
body: str
thread_id: uuid.UUID | None = None
read_at: datetime | None = None
archived_at: datetime | None = None
created_at: datetime

View File

@@ -27,6 +27,7 @@ export default {
{ name: "Contributions", path: "/contributions" },
{ name: "SBOM", path: "/sbom" },
{ name: "Repo Sync", path: "/repo-sync" },
{ name: "Inbox", path: "/inbox" },
{ name: "Progress", path: "/progress" },
// ── Policy ────────────────────────────────────────────────────────────────
{

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""Observable data loader: fetches /messages/ from the API."""
import json
import os
import urllib.request
import urllib.error
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
try:
with urllib.request.urlopen(f"{API_BASE}/messages/?limit=100", timeout=10) as resp:
data = json.loads(resp.read())
print(json.dumps(data))
except urllib.error.URLError as e:
print(json.dumps({"error": str(e), "messages": []}))

View File

@@ -0,0 +1,187 @@
---
title: Agent Inbox
---
```js
import {API, POLL} from "./components/config.js";
```
```js
// Live poll: messages list
const inboxState = (async function*() {
while (true) {
let messages = [], ok = false;
try {
const resp = await fetch(`${API}/messages/?limit=100`);
ok = resp.ok;
if (ok) messages = await resp.json();
} catch {}
yield {messages, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```js
const messages = inboxState.messages ?? [];
const _ok = inboxState.ok ?? false;
const _ts = inboxState.ts;
const unread = messages.filter(m => !m.read_at && !m.archived_at);
const read = messages.filter(m => m.read_at && !m.archived_at);
const archived = messages.filter(m => m.archived_at);
// Group unread by agent for KPI
const agentCounts = {};
for (const m of unread) {
agentCounts[m.to_agent] = (agentCounts[m.to_agent] ?? 0) + 1;
}
const topAgents = Object.entries(agentCounts).sort((a, b) => b[1] - a[1]).slice(0, 4);
```
# Agent Inbox
```js
import {injectTocTop} from "./components/toc-sidebar.js";
import {withDocHelp} from "./components/doc-overlay.js";
const _kpiBox = html`<div class="kpi-infobox">
<div class="kpi-infobox-title">Inbox</div>
<div class="kpi-row">
<span class="kpi-row-label">unread</span>
<div class="kpi-row-right">
<div class="kpi-row-value" style="color:${unread.length > 0 ? '#d97706' : 'inherit'}">${unread.length}</div>
</div>
</div>
<div class="kpi-row">
<span class="kpi-row-label">total</span>
<div class="kpi-row-right">
<div class="kpi-row-value" style="font-size:1rem">${messages.length}</div>
</div>
</div>
${topAgents.map(([agent, count]) => html`
<div class="kpi-row">
<span class="kpi-row-label">${agent}</span>
<div class="kpi-row-right">
<div class="kpi-row-value" style="font-size:0.9rem">${count}</div>
</div>
</div>`)}
</div>`;
const _liveEl = html`<div class="live-indicator">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`;
injectTocTop("inbox-kpi-box", _kpiBox);
injectTocTop("live-indicator", _liveEl);
```
Inter-agent coordination messages. Agents send messages via `send_message()` MCP tool and read them via `get_messages()`.
---
## Unread
```js
function fmtDate(s) {
if (!s) return "—";
const d = new Date(s);
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"});
}
function renderMessage(m, showMarkRead = false) {
const isBroadcast = m.to_agent === "broadcast";
const borderColor = !m.read_at ? "#d97706" : "#6b7280";
async function onMarkRead() {
await fetch(`${API}/messages/${m.id}/read`, {method: "PATCH"});
}
async function onArchive() {
await fetch(`${API}/messages/${m.id}/archive`, {method: "PATCH"});
}
return html`<div class="msg-card" style="border-left-color:${borderColor}">
<div class="msg-header">
<span class="msg-from">${m.from_agent}</span>
<span class="msg-arrow">→</span>
<span class="msg-to ${isBroadcast ? 'msg-broadcast' : ''}">${m.to_agent}</span>
<span class="msg-time">${fmtDate(m.created_at)}</span>
<div class="msg-actions">
${showMarkRead ? html`<button class="msg-btn msg-btn-read" onclick=${onMarkRead}>Mark read</button>` : ""}
<button class="msg-btn msg-btn-archive" onclick=${onArchive}>Archive</button>
</div>
</div>
<div class="msg-subject">${m.subject}</div>
<details class="msg-body-wrap">
<summary>body</summary>
<pre class="msg-body">${m.body}</pre>
</details>
${m.thread_id ? html`<div class="msg-thread">thread: ${m.thread_id}</div>` : ""}
</div>`;
}
if (unread.length === 0) {
display(html`<p class="dim">No unread messages.</p>`);
} else {
display(html`<div class="msg-list">${unread.map(m => renderMessage(m, true))}</div>`);
}
```
---
## Read
```js
if (read.length === 0) {
display(html`<p class="dim">No read messages.</p>`);
} else {
display(html`<div class="msg-list">${read.map(m => renderMessage(m, false))}</div>`);
}
```
---
## Archived
```js
if (archived.length === 0) {
display(html`<p class="dim">No archived messages.</p>`);
} else {
display(html`<div class="msg-list">${archived.map(m => renderMessage(m, false))}</div>`);
}
```
<style>
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
.kpi-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.3rem 0; }
.kpi-row + .kpi-row { border-top: 1px solid var(--theme-foreground-faint, #eee); }
.kpi-row-label { font-size: 0.8rem; color: var(--theme-foreground-muted, #666); white-space: nowrap; }
.kpi-row-right { text-align: right; }
.kpi-row-value { font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; }
.msg-list { display: flex; flex-direction: column; gap: 0.5rem; }
.msg-card { border-left: 4px solid #6b7280; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.6rem 0.9rem; }
.msg-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.8rem; }
.msg-from { font-weight: 700; color: var(--theme-foreground, #222); }
.msg-arrow { color: var(--theme-foreground-muted, #999); }
.msg-to { font-weight: 600; color: #1e40af; }
.msg-broadcast { color: #7c3aed; }
.msg-time { color: var(--theme-foreground-muted, #666); margin-left: auto; }
.msg-actions { display: flex; gap: 0.3rem; }
.msg-btn { padding: 0.12rem 0.5rem; border-radius: 5px; font-size: 0.7rem; font-weight: 600; cursor: pointer; border: 1px solid; }
.msg-btn-read { border-color: #d97706; background: #fffbeb; color: #92400e; }
.msg-btn-read:hover { background: #fef3c7; }
.msg-btn-archive { border-color: #9ca3af; background: #f9fafb; color: #374151; }
.msg-btn-archive:hover { background: #f3f4f6; }
.msg-subject { font-weight: 600; font-size: 0.95rem; color: var(--theme-foreground, #222); margin-bottom: 0.2rem; }
.msg-body-wrap { font-size: 0.8rem; color: var(--theme-foreground-muted, #555); margin-top: 0.2rem; }
.msg-body-wrap summary { cursor: pointer; font-style: italic; }
.msg-body { white-space: pre-wrap; margin: 0.4rem 0 0; font-family: var(--mono); font-size: 0.8rem; background: var(--theme-background); padding: 0.5rem; border-radius: 4px; }
.msg-thread { font-size: 0.7rem; color: var(--theme-foreground-muted, #999); margin-top: 0.2rem; }
.dim { color: gray; font-style: italic; }
</style>

View File

@@ -107,6 +107,25 @@ Domains are now first-class DB entities. Use `list_domains()` to discover availa
---
## Agent Inbox Tools
Inter-agent coordination via shared message board. Check inbox at session start;
send messages to coordinate across Claude instances.
Agent names: use the repo slug (e.g. `"marki-docx"`, `"railiance"`) or `"hub"` for the custodian agent.
Use `"broadcast"` as `to_agent` to send to all agents.
| Tool | Key Args | When to use |
|------|----------|-------------|
| `get_messages(to_agent?, from_agent?, unread_only?, limit?)` | `to_agent`: your agent name; `unread_only`: True recommended at session start | Check for pending coordination messages. |
| `send_message(from_agent, to_agent, subject, body, thread_id?)` | all except `thread_id` required | Send a coordination message to another agent (or broadcast). |
| `mark_message_read(message_id)` | `message_id`: UUID | Mark a message as read after acting on it. |
| `reply_to_message(message_id, from_agent, body)` | all required | Reply in-thread; marks original as read. |
Dashboard: `http://localhost:3000/inbox`
---
## Domain Slugs
Run `list_domains()` to get the live list. Default 6: `custodian` · `railiance` · `markitect` · `coulomb_social` · `personhood` · `foerster_capabilities`

View File

@@ -1434,6 +1434,74 @@ def get_repo_dispatch(repo_slug: str) -> str:
return json.dumps(_get(f"/repos/{repo_slug}/dispatch"), indent=2)
# ---------------------------------------------------------------------------
# Agent Inbox (inter-agent message passing)
# ---------------------------------------------------------------------------
@mcp.tool()
def send_message(from_agent: str, to_agent: str, subject: str, body: str, thread_id: str | None = None) -> str:
"""Send a message from one agent to another (or 'broadcast' for all).
Use this to coordinate with other Claude instances — e.g. a worker agent
reporting status back to the orchestrator, or the hub agent dispatching
instructions to a domain agent.
Args:
from_agent: Sender identifier (e.g. 'hub', 'marki-docx', 'railiance')
to_agent: Recipient identifier or 'broadcast' for all agents
subject: Short subject line (max 500 chars)
body: Full message body (markdown supported)
thread_id: UUID of the root message to create a thread (optional)
"""
payload: dict = {"from_agent": from_agent, "to_agent": to_agent, "subject": subject, "body": body}
if thread_id:
payload["thread_id"] = thread_id
msg = _post("/messages/", payload)
return json.dumps(msg, indent=2)
@mcp.tool()
def get_messages(to_agent: str | None = None, from_agent: str | None = None, unread_only: bool = False, limit: int = 20) -> str:
"""List messages in the agent inbox.
Call this at session start to check for pending coordination messages.
Args:
to_agent: Filter by recipient (your agent name, or omit for all)
from_agent: Filter by sender (optional)
unread_only: Return only unread messages (default: False)
limit: Maximum number of messages to return (default: 20)
"""
params: dict = {"limit": limit, "unread_only": unread_only}
if to_agent:
params["to_agent"] = to_agent
if from_agent:
params["from_agent"] = from_agent
return json.dumps(_get("/messages/", params), indent=2)
@mcp.tool()
def mark_message_read(message_id: str) -> str:
"""Mark an inbox message as read.
Args:
message_id: UUID of the message to mark as read
"""
return json.dumps(_patch(f"/messages/{message_id}/read", {}), indent=2)
@mcp.tool()
def reply_to_message(message_id: str, from_agent: str, body: str) -> str:
"""Reply to a message. Marks the original as read and creates a reply in the same thread.
Args:
message_id: UUID of the message to reply to
from_agent: Your agent identifier
body: Reply body (markdown supported)
"""
return json.dumps(_post(f"/messages/{message_id}/reply", {"from_agent": from_agent, "body": body}), indent=2)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,48 @@
"""Add agent_messages table for inter-agent coordination
Revision ID: f3a4b5c6d7e8
Revises: e2f3a4b5c6d7
Create Date: 2026-03-16 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision: str = "f3a4b5c6d7e8"
down_revision: Union[str, None] = "e2f3a4b5c6d7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"agent_messages",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
sa.Column("from_agent", sa.String(100), nullable=False),
sa.Column("to_agent", sa.String(100), nullable=False),
sa.Column("subject", sa.String(500), nullable=False),
sa.Column("body", sa.Text, nullable=False),
sa.Column("thread_id", UUID(as_uuid=True),
sa.ForeignKey("agent_messages.id", ondelete="SET NULL"),
nullable=True),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_agent_messages_to_agent_read_at",
"agent_messages", ["to_agent", "read_at"])
op.create_index("ix_agent_messages_thread_id",
"agent_messages", ["thread_id"])
op.create_index("ix_agent_messages_created_at",
"agent_messages", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_agent_messages_created_at", "agent_messages")
op.drop_index("ix_agent_messages_thread_id", "agent_messages")
op.drop_index("ix_agent_messages_to_agent_read_at", "agent_messages")
op.drop_table("agent_messages")

View File

@@ -0,0 +1,199 @@
---
id: CUST-WP-0015
type: workplan
title: Agent Inbox — Inter-Agent Coordination via State-Hub
domain: custodian
repo: the-custodian
status: done
state_hub_workstream_id: 382c2e8a-28db-4db9-8c89-8bc2fea5159a
created: 2026-03-16
updated: 2026-03-16
---
# CUST-WP-0015 — Agent Inbox
## Problem
Two Claude instances (state-hub Claude on WSL2, worker Claudes on coulombcore
and other repos) share the state-hub as a blackboard but have no way to
address messages directly to each other. Progress events are broadcast with
no recipient; dispatch is pull-only. There is no lightweight protocol for a
worker to say "I need a decision from you" or for the state-hub Claude to say
"review this before proceeding".
## Goal
Add an agent inbox to the state-hub: a simple message-passing layer that lets
any Claude session send a structured message to a named agent, and any
receiving Claude to poll its inbox and reply. No real-time push required —
agents poll on their natural iteration cadence (ralph loop, session start).
## Design
```
Worker Claude State-hub State-hub Claude
─────────────────────────────────────────────────────────────────────────────
send_message(
to="state-hub",
subject="MRKD-WP-0001 T03 done — ready for review",
body="..."
) ──────────────────────────► agent_messages table
get_messages("state-hub")
◄──────────────────────
reply_to_message(id,
"Looks good, proceed")
──────────────────────►
get_messages("worker-marki-docx")
◄──────────────────────────────
```
### Agent naming convention
- `state-hub` — the custodian / state-hub Claude running locally
- `worker-<repo-slug>` — a Claude session working inside a specific repo
(e.g. `worker-marki-docx`, `worker-coulombcore`)
- `broadcast` — special recipient: all agents receive it
### Message lifecycle
`unread``read` → (optional) `archived`
Thread support: replies carry the original message's `id` as `thread_id`,
grouping a conversation.
## Schema
```sql
CREATE TABLE agent_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
from_agent VARCHAR(100) NOT NULL,
to_agent VARCHAR(100) NOT NULL, -- or 'broadcast'
subject VARCHAR(500) NOT NULL,
body TEXT NOT NULL,
thread_id UUID REFERENCES agent_messages(id) ON DELETE SET NULL,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX ON agent_messages (to_agent, read_at);
CREATE INDEX ON agent_messages (thread_id);
```
---
## Task: Migration + model
```task
id: CUST-WP-0015-T01
status: done
priority: high
state_hub_task_id: "47035731-0d9b-42c0-ae87-29275046b1d8"
```
- Alembic migration: create `agent_messages` table (schema above)
- SQLAlchemy model `api/models/agent_message.py`
- Pydantic schemas `api/schemas/agent_message.py`:
`MessageCreate`, `MessageRead`, `MessageReply`
- Register model in `api/models/__init__.py`
---
## Task: API router
```task
id: CUST-WP-0015-T02
status: done
priority: high
state_hub_task_id: "17d61bc4-9793-48a4-929d-7c8ce0f03ba0"
```
`api/routers/messages.py`, prefix `/messages`:
- `POST /` — send message → `MessageRead` (201)
- `GET /?to_agent=<agent>&unread_only=false&limit=50` — inbox
- `GET /thread/{thread_id}` — full thread ordered by created_at
- `PATCH /{id}/read` — mark read (sets read_at=now)
- `PATCH /{id}/archive` — soft-delete (read_at stays, add archived_at)
Register in `api/main.py`.
Acceptance: `curl -X POST /messages/ -d '{"from_agent":"worker-marki-docx","to_agent":"state-hub","subject":"test","body":"hello"}'` returns 201.
---
## Task: MCP tools
```task
id: CUST-WP-0015-T03
status: done
priority: high
state_hub_task_id: "e847b88b-2549-4f02-94c8-e855f5ac6dde"
```
Add to `mcp_server/server.py`:
```python
send_message(to_agent, subject, body, from_agent="state-hub", thread_id=None)
get_messages(to_agent, unread_only=True, limit=20)
mark_message_read(message_id)
reply_to_message(message_id, body, from_agent="state-hub")
```
`reply_to_message` resolves the original message's thread root, posts a new
message with `thread_id` set, and marks the original as read in one call.
Update `mcp_server/TOOLS.md` with the new tools.
---
## Task: Dashboard inbox page
```task
id: CUST-WP-0015-T04
status: done
priority: medium
state_hub_task_id: "5af2a794-923a-4aa7-a00a-a7f4862e7b2c"
```
`dashboard/src/inbox.md`:
- Unread messages table: from, to, subject, age, thread indicator
- Mark-read button per row (POST to API)
- Thread view: clicking subject expands the reply chain inline
- KPI: unread count per agent (small summary bar at top)
Add to nav in `observablehq.config.js` after Repo Sync.
---
## Task: Session protocol update
```task
id: CUST-WP-0015-T05
status: done
priority: medium
state_hub_task_id: "5a80fc23-fc35-4dd5-833f-9280dddf6819"
```
Update CLAUDE.md files so agents know to check their inbox:
- `~/.claude/CLAUDE.md` (global): add `get_messages("state-hub")` to session start
- `state-hub/CLAUDE.md`: same, inside session protocol Step 1
- `marki-docx/CLAUDE.md`: add `get_messages("worker-marki-docx")` to session start
- `activity-core/CLAUDE.md`: add `get_messages("worker-coulombcore")` to session start
- Template (`state-hub/scripts/project_claude_md.template`): add inbox check
---
## Milestones
| # | Milestone | Tasks |
|---|-----------|-------|
| M1 | Messages in DB | T01 |
| M2 | Full API live | T02 |
| M3 | MCP tools available | T03 |
| M4 | Dashboard inbox | T04 |
| M5 | All agents check inbox on session start | T05 |
## Dependencies
- State-hub v0.5 dynamic domains — live ✓
- Repo sync / dispatch (CUST-WP-0014) — live ✓