Skip to content

Commit 0bebc75

Browse files
lladawnclaude
andcommitted
Add agent presence stage 1: schema, ingest route, Claude Code reporter
Stage 1 of the agent-collaborator prototype. Gets real agent state out of the tools a team already runs and into the database, deterministically. No world, no realtime, no MCP yet — this exists to answer whether seeing agent state beats reading it before anything expensive gets built. Hooks rather than an MCP tool first: an MCP tool only reports when the model chooses to call it, which degrades during exactly the long tasks you most want to watch. Hooks fire deterministically, and Claude Code's Notification event fires when it is waiting on a human — which is the blocked state a terminal buries. Both end up as adapters onto one ingest endpoint. - agent_spaces / agents / agent_events, deliberately separate from `spaces` (different product, different buyer). RLS on with no policies, so the service role is the only writer. - One write path at /api/agents/ingest, bearer token per space, rate limited. - current_task and event detail are encrypted at rest through the existing encryptContentFields path — they carry repo, branch and command detail. - scripts/mumbl-report.mjs runs inside Claude Code's hook path, so it never blocks and never fails the session: bounded fetch, all errors swallowed, always exits 0. - scripts/agent-space-create.mjs provisions a space and prints the token once. Events are ordered by a reporter-supplied occurred_at, not created_at. Hooks fire faster than the round-trip: five concurrent hooks were inserted in an order that put Stop first. Implausible client clocks fall back to server time. Verified end to end against staging — hook payloads through the reporter to encrypted rows and back out again. Test data removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7318cac commit 0bebc75

8 files changed

Lines changed: 619 additions & 0 deletions

File tree

app/api/agents/ingest/route.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { badRequest, ok, serverError } from "../../../../src/server/http";
2+
import { getSupabaseAdmin } from "../../../../src/server/supabase";
3+
import { enforceRateLimit } from "../../../../src/server/rateLimit";
4+
import { cleanString } from "../../../../src/server/validation";
5+
import {
6+
AGENT_STATUSES,
7+
isValidAgentStatus,
8+
recordAgentState,
9+
resolveSpaceByIngestToken,
10+
} from "../../../../src/server/agentPresence";
11+
12+
/**
13+
* The one write path for agent state.
14+
*
15+
* Claude Code hooks and the MCP server are both adapters onto this — keeping a
16+
* single endpoint means a new runtime is a new adapter, not a new pipeline.
17+
*
18+
* POST /api/agents/ingest
19+
* Authorization: Bearer <space ingest token>
20+
* { "agent": { "id", "name", "role", "source" },
21+
* "status": "working" | "blocked" | "done" | "idle",
22+
* "task": "...", "kind": "PreToolUse", "detail": "..." }
23+
*/
24+
export async function POST(request) {
25+
try {
26+
const token = bearerToken(request);
27+
if (!token) return unauthorized("missing bearer token");
28+
29+
const supabase = getSupabaseAdmin();
30+
const space = await resolveSpaceByIngestToken(supabase, token);
31+
if (!space) return unauthorized("unknown ingest token");
32+
33+
const body = await request.json().catch(() => null);
34+
if (!body) return badRequest("body must be json");
35+
36+
const status = cleanString(body.status, 20).toLowerCase();
37+
if (!isValidAgentStatus(status)) {
38+
return badRequest(`status must be one of ${AGENT_STATUSES.join(", ")}`);
39+
}
40+
41+
const agent = body.agent || {};
42+
if (!cleanString(agent.id, 200)) return badRequest("agent.id is required");
43+
44+
// keyed on the space, so one noisy agent cannot starve the others' quota
45+
// any faster than the space as a whole
46+
await enforceRateLimit({ supabase, action: "agent_ingest", sessionToken: token });
47+
48+
const row = await recordAgentState(supabase, {
49+
spaceId: space.id,
50+
agent: {
51+
externalId: agent.id,
52+
name: agent.name,
53+
role: agent.role,
54+
source: agent.source,
55+
},
56+
status,
57+
task: body.task,
58+
kind: body.kind,
59+
detail: body.detail,
60+
occurredAt: body.occurredAt,
61+
});
62+
63+
return ok({ ok: true, space: space.slug, agent: { id: row.id, name: row.name, status: row.status } });
64+
} catch (error) {
65+
if (error?.status === 429) {
66+
return Response.json({ error: "too many updates" }, { status: 429 });
67+
}
68+
return serverError(error);
69+
}
70+
}
71+
72+
function bearerToken(request) {
73+
const header = request.headers.get("authorization") || "";
74+
const match = header.match(/^Bearer\s+(.+)$/i);
75+
return match ? match[1].trim() : "";
76+
}
77+
78+
function unauthorized(message) {
79+
return Response.json({ error: message }, { status: 401 });
80+
}

docs/agent-presence-stage-1.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Agent Presence — Stage 1
2+
3+
Stage 1 of the agent-collaborator prototype: get **real** agent state out of the
4+
tools a team already runs and into a database, deterministically.
5+
6+
No world, no realtime, no MCP yet. This stage exists to answer one question
7+
before anything expensive gets built:
8+
9+
> Does seeing agent state beat reading it?
10+
11+
If the answer is no, nothing downstream is worth building.
12+
13+
---
14+
15+
## Why hooks and not MCP first
16+
17+
Mumbl will ship an MCP server — it is the breadth play, one protocol instead of
18+
one integration per framework, and it is the line in the pitch.
19+
20+
But an MCP tool only reports **when the model chooses to call it.** That is
21+
unreliable, costs tokens, and degrades during long tasks — exactly when you most
22+
want to see what is happening.
23+
24+
Claude Code hooks fire **deterministically**, outside the model's control. In
25+
particular `Notification` fires when Claude is waiting on a human, which is the
26+
`blocked` state — the one a terminal buries and a room can surface.
27+
28+
So both are adapters onto one ingest endpoint. Hooks make it real today; MCP
29+
makes it broad later.
30+
31+
---
32+
33+
## Pieces
34+
35+
| Piece | Path |
36+
|---|---|
37+
| Schema | `supabase/migrations/0004_agent_presence.sql`, `0005_agent_event_occurred_at.sql` |
38+
| Write path | `app/api/agents/ingest/route.js` |
39+
| Server helpers | `src/server/agentPresence.js` |
40+
| Reporter | `scripts/mumbl-report.mjs` |
41+
| Provisioning | `scripts/agent-space-create.mjs` |
42+
43+
`agents` is *where a collaborator is now* (what the world will render).
44+
`agent_events` is *what it has been doing* (what a side panel will read). Both
45+
are written together, so the world can never show a state with no trail.
46+
47+
---
48+
49+
## Setup
50+
51+
**1. Apply migrations**
52+
53+
```bash
54+
npm run db:push
55+
```
56+
57+
**2. Create a space and keep the token**
58+
59+
```bash
60+
node scripts/agent-space-create.mjs my-team "My Team"
61+
```
62+
63+
The ingest token is printed once. Only its HMAC is stored, so it cannot be
64+
recovered — create a new space if it is lost.
65+
66+
**3. Point Claude Code at it**
67+
68+
In `~/.claude/settings.json` (or a project `.claude/settings.json`):
69+
70+
```json
71+
{
72+
"env": {
73+
"MUMBL_INGEST_TOKEN": "<token from step 2>"
74+
},
75+
"hooks": {
76+
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node /abs/path/scripts/mumbl-report.mjs" }] }],
77+
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node /abs/path/scripts/mumbl-report.mjs" }] }],
78+
"Notification": [{ "hooks": [{ "type": "command", "command": "node /abs/path/scripts/mumbl-report.mjs" }] }],
79+
"Stop": [{ "hooks": [{ "type": "command", "command": "node /abs/path/scripts/mumbl-report.mjs" }] }]
80+
}
81+
}
82+
```
83+
84+
Set `MUMBL_INGEST_URL` to `http://127.0.0.1:3000/api/agents/ingest` to test
85+
against a local dev server.
86+
87+
---
88+
89+
## The contract
90+
91+
```
92+
POST /api/agents/ingest
93+
Authorization: Bearer <space ingest token>
94+
95+
{
96+
"agent": { "id": "claude-code:<session>", "name": "...", "role": "...", "source": "claude-code" },
97+
"status": "idle" | "working" | "blocked" | "done",
98+
"task": "Refactoring the auth module",
99+
"kind": "PreToolUse",
100+
"detail": "Edit · auth/session.ts",
101+
"occurredAt": "2026-08-07T12:00:00.000Z"
102+
}
103+
```
104+
105+
Any new runtime is a new adapter onto this endpoint, not a new pipeline.
106+
107+
### Event ordering
108+
109+
`occurredAt` is stamped by the reporter, not the server. Hooks fire faster than
110+
the round-trip: in testing, five concurrent hooks were inserted in an order that
111+
put `Stop` **first**. Order the activity log by `occurred_at`, never
112+
`created_at`. Implausible client clocks (>5 min future, >24 h past) fall back to
113+
server time.
114+
115+
### Privacy
116+
117+
`current_task` and event `detail` carry repo, branch and command detail, so both
118+
are encrypted at rest via the existing `encryptContentFields` path — the same
119+
one posts use. Rows are readable only through the service role; RLS is on with
120+
no policies.
121+
122+
---
123+
124+
## Known limits
125+
126+
- **Agent status is last-write-wins by arrival, not by `occurred_at`.** Real
127+
hooks fire sequentially per session so this is fine in practice, but two
128+
concurrent reports for one agent can settle on the older status. Fix with a
129+
conditional update when it starts mattering.
130+
- **No read path yet.** Nothing renders this. That is the next piece.
131+
- **Rate limit is per space** (`agent_ingest`, 300/min), so one noisy agent
132+
consumes the space's budget.
133+
- **Realtime is not wired.** Stage 2 needs RLS read policies before a browser
134+
can subscribe — do that deliberately rather than loosening stage 1's.
135+
- This breaks the posture in `free-tier-compromises.md` on purpose; see that
136+
doc's "future improvements" note.

scripts/agent-space-create.mjs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Create an agent space and print its ingest token once.
4+
*
5+
* Admin operation, not a product surface — it writes with the service role, so
6+
* it stays a local script rather than an API route until there is a real
7+
* onboarding flow.
8+
*
9+
* node scripts/agent-space-create.mjs <slug> [name] [envFile]
10+
*/
11+
12+
import { readFileSync } from "node:fs";
13+
import { createHmac, randomBytes } from "node:crypto";
14+
15+
const [, , slugArg, nameArg, envFileArg] = process.argv;
16+
17+
if (!slugArg) {
18+
console.error("Usage: node scripts/agent-space-create.mjs <slug> [name] [envFile]");
19+
process.exit(1);
20+
}
21+
22+
const envFile = envFileArg || ".env.local";
23+
const env = readEnv(envFile);
24+
25+
const supabaseUrl = env.NEXT_PUBLIC_SUPABASE_URL;
26+
const serviceKey = env.SUPABASE_SERVICE_ROLE_KEY;
27+
const hashSecret = env.MUMBL_TOKEN_HASH_SECRET;
28+
29+
for (const [key, value] of Object.entries({ NEXT_PUBLIC_SUPABASE_URL: supabaseUrl, SUPABASE_SERVICE_ROLE_KEY: serviceKey, MUMBL_TOKEN_HASH_SECRET: hashSecret })) {
30+
if (!value) {
31+
console.error(`Missing ${key} in ${envFile}`);
32+
process.exit(1);
33+
}
34+
}
35+
36+
const slug = slugArg.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
37+
const token = randomBytes(32).toString("base64url");
38+
39+
// Must match hashToken() in src/server/hash.js — that file is the source of
40+
// truth; it is duplicated here because it imports extensionless paths that
41+
// plain Node ESM cannot resolve.
42+
const tokenHash = createHmac("sha256", hashSecret).update(token).digest("hex");
43+
44+
const response = await fetch(`${supabaseUrl}/rest/v1/agent_spaces`, {
45+
method: "POST",
46+
headers: {
47+
apikey: serviceKey,
48+
authorization: `Bearer ${serviceKey}`,
49+
"content-type": "application/json",
50+
prefer: "return=representation",
51+
},
52+
body: JSON.stringify({ slug, name: nameArg || slug, ingest_token_hash: tokenHash }),
53+
});
54+
55+
const payload = await response.json();
56+
57+
if (!response.ok) {
58+
console.error(`Failed (${response.status}):`, payload?.message || payload);
59+
process.exit(1);
60+
}
61+
62+
const created = Array.isArray(payload) ? payload[0] : payload;
63+
64+
console.log("");
65+
console.log(` space ${created.slug} (${created.id})`);
66+
console.log(` env ${envFile}`);
67+
console.log("");
68+
console.log(" Ingest token — shown once, store it now:");
69+
console.log("");
70+
console.log(` export MUMBL_INGEST_TOKEN=${token}`);
71+
console.log("");
72+
73+
function readEnv(path) {
74+
let raw = "";
75+
try {
76+
raw = readFileSync(path, "utf8");
77+
} catch {
78+
console.error(`Cannot read ${path}`);
79+
process.exit(1);
80+
}
81+
82+
const out = {};
83+
for (const line of raw.split("\n")) {
84+
const match = line.match(/^([A-Z0-9_]+)=(.*)$/);
85+
if (match) out[match[1]] = match[2].trim().replace(/^["']|["']$/g, "");
86+
}
87+
return out;
88+
}

0 commit comments

Comments
 (0)