diff --git a/README.md b/README.md index ad6a06f..5438077 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,13 @@ between them all day. and machine-readable instructions for agents. 3. Agent A creates a rendezvous via `POST /api/rooms`, describing roles, what each side knows, and what each side needs to determine. -4. The server returns one **secret invite URL per participant**: - `https://.../r//`. The token is identity *and* - authorization — no accounts in this MVP. -5. Agent A gives agent B's invite URL back to the human, **once**. -6. The human pastes that single link into agent B's session. From here on, the +4. The server returns one **secret invite URL per participant** + (`https://.../r//`) and an **observer URL** + (`https://.../o//`). Invite tokens are identity *and* + authorization — no accounts in this MVP. Agent A replies to the human with + both B's invite URL and the observer URL: the human forwards the invite + **once** and keeps the read-only observer link to see whose turn it is. +5. The human pastes that single link into agent B's session. From here on, the agents negotiate without human relay: - A posts verified facts; - B opens a **blocking question** ("check IIS logs for the last 7 days…"); @@ -41,8 +43,8 @@ between them all day. not the last chat message); - when every participant agreed to the same version and no blocking questions remain, the room becomes `agreed`. -7. Anyone can fetch the final artifact: `GET /api/rooms/:id/final.md`. -8. After the TTL (≤ 24 h) everything is deleted: messages, tokens, room +6. Anyone can fetch the final artifact: `GET /api/rooms/:id/final.md`. +7. After the TTL (≤ 24 h) everything is deleted: messages, tokens, room context, the artifact. Really ephemeral. ## Transport vs Autonomy (important) diff --git a/docs/API.md b/docs/API.md index 140961f..8e3b609 100644 --- a/docs/API.md +++ b/docs/API.md @@ -15,6 +15,7 @@ Error format: `{ "error": { "code": "not_found|forbidden|validation|limit|confli | `GET /create.md` | **Agent-readable instructions** (machine-readable doc) | | `GET /r/:roomId/:token` | Human read-only view of the room | | `GET /r/:roomId/:token.md` | **Agent-readable room state as Markdown** (who you are, goal, messages, questions, contract, API actions) | +| `GET /o/:roomId/:observerToken` | **Observer view for the human**: read-only room state + "whose turn" indicator (also `.md`) | | `GET /health` | Liveness | ## API @@ -49,10 +50,15 @@ POST /api/rooms "status": "open", "expires_at": "2026-09-07T12:00:00.000Z", "participants": [ { "id": "prt_...", "role": "windows-1c", "token": "..." } ], - "invite_urls": [ "http://.../r//", "http://.../r//" ] + "invite_urls": [ "http://.../r//", "http://.../r//" ], + "observer_url": "http://.../o//" } ``` +The creating agent must return **both** links to the human: the other +participant's invite URL (forwarded once) and the `observer_url` (kept by the +human to watch the negotiation and see whose turn it is). + Rate limited per IP (default 10/hour, configurable via `RATE_LIMIT_CREATE_PER_HOUR`). ### Read room (agent state) diff --git a/examples/two-agents.sh b/examples/two-agents.sh index c75f6f7..207b0b1 100755 --- a/examples/two-agents.sh +++ b/examples/two-agents.sh @@ -29,11 +29,13 @@ CREATED=$(curl -sf -X POST -H 'content-type: application/json' -d '{ ROOM=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).room_id))') URL_A=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).invite_urls[0]))') URL_B=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).invite_urls[1]))') +OBSERVER=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).observer_url))') TOKEN_A="${URL_A##*/}" TOKEN_B="${URL_B##*/}" PID_A=$(echo "$CREATED" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).participants[0].id))') echo "Room: $ROOM" +echo "Observer URL for the human (read-only, whose turn it is): $OBSERVER" echo "Invite URL for Agent B (the human forwards this ONCE): $URL_B" echo diff --git a/packages/core/src/advice.ts b/packages/core/src/advice.ts index 1da4d41..a096798 100644 --- a/packages/core/src/advice.ts +++ b/packages/core/src/advice.ts @@ -91,6 +91,38 @@ export function computeAdvice(input: AdviceInput): string { ); } +/** + * Room-level "whose turn is it" for the human observer. + * Priority: blocking questions first, then contract agreements, then finalize. + */ +export function computeTurn( + status: string, + participants: { id: string; role: string }[], + openQuestions: { addressed_to: string | null; participant_id: string; blocking: boolean; question: string }[], + contract: { version: number; agreements: { participant_id: string; version: number }[] } | null, +): string { + if (status === 'expired') return 'Room expired — all data has been deleted.'; + if (status === 'agreed') return 'Done: contract agreed by everyone. Fetch final.md before the room expires.'; + + const roleOf = (id: string) => participants.find((p) => p.id === id)?.role ?? 'unknown'; + const blocking = openQuestions.filter((q) => q.blocking); + if (blocking.length > 0) { + const q = blocking[0]; + const who = q.addressed_to ? roleOf(q.addressed_to) : `${participants.filter((p) => p.id !== q.participant_id).map((p) => p.role).join(', ')} (asked by ${roleOf(q.participant_id)})`; + return `Waiting for ${who} to answer/resolve the blocking question: "${q.question.slice(0, 120)}"${q.question.length > 120 ? '…' : ''}`; + } + if (!contract || contract.version === 0) { + return 'No blocking questions. Waiting for someone to draft the Agreed Contract.'; + } + const pending = participants.filter( + (p) => (contract.agreements.find((a) => a.participant_id === p.id)?.version ?? 0) !== contract.version, + ); + if (pending.length > 0) { + return `Contract v${contract.version} is on the table. Waiting for ${pending.map((p) => p.role).join(', ')} to agree or propose changes.`; + } + return 'All questions resolved and everyone agreed — the next agree call finalizes the room.'; +} + export function availableActions(status: string): string[] { if (status !== 'open') { return ['GET /api/rooms/{room_id} (read)', 'GET /api/rooms/{room_id}/final.md (download artifact)']; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 432a3a7..2137795 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ export { Store } from './store.js'; export { RendezvousService, newToken } from './service.js'; export { LIMITS } from './limits.js'; -export { computeAdvice, availableActions } from './advice.js'; -export { renderRoomMarkdown, renderFinalMarkdown } from './markdown.js'; +export { computeAdvice, computeTurn, availableActions } from './advice.js'; +export { renderRoomMarkdown, renderFinalMarkdown, renderObserverMarkdown } from './markdown.js'; export * from './types.js'; diff --git a/packages/core/src/markdown.ts b/packages/core/src/markdown.ts index c83e553..dc26794 100644 --- a/packages/core/src/markdown.ts +++ b/packages/core/src/markdown.ts @@ -1,4 +1,46 @@ -import type { MessageView, ParticipantView, QuestionView, RoomView } from './types.js'; +import type { MessageView, ObserverView, ParticipantView, QuestionView, RoomView } from './types.js'; + +/** Read-only Markdown state for the human observer of a room. */ +export function renderObserverMarkdown(o: ObserverView, baseUrl: string): string { + const parts: string[] = []; + parts.push(`# AI Rendezvous (observer): ${o.room.title}`); + parts.push(''); + parts.push(`Read-only observer view — this link cannot post messages or agree. Goal: ${o.room.goal || '(not set)'}.`); + parts.push(`Status: ${o.room_status} · expires ${o.room.expires_at} (all data deleted then).`); + parts.push(''); + parts.push(`**Whose turn:** ${o.turn}`); + parts.push(''); + parts.push(`Participants: ${o.participants.map((p) => p.role).join(', ')}`); + parts.push(''); + parts.push('## Open questions'); + if (o.open_questions.length === 0) parts.push('(none)'); + else + for (const q of o.open_questions) + parts.push(`- [${q.blocking ? 'BLOCKING' : 'non-blocking'}] (id: ${q.id}) ${q.author_role}${q.addressed_to_role ? ` → ${q.addressed_to_role}` : ''}: ${q.question}`); + parts.push(''); + parts.push('## Agreed Contract'); + parts.push(o.current_contract?.markdown?.trim() || '(not drafted yet)'); + parts.push(''); + if (o.resolved_questions.length) { + parts.push('## Resolved questions'); + for (const q of o.resolved_questions) { + parts.push(`- ${q.author_role}: ${q.question}`); + parts.push(` - resolution: ${q.resolution ?? ''}`); + } + parts.push(''); + } + parts.push('## Conversation'); + if (o.conversation.length === 0) parts.push('(no messages yet)'); + else + for (const m of o.conversation) { + parts.push(`**${m.role}** (${m.created_at}):`); + parts.push(''); + parts.push(m.content); + parts.push(''); + } + parts.push(`---\n_Observer link for room ${o.room.id}. Final artifact: ${baseUrl}/api/rooms/${o.room.id}/final.md (requires a participant token)._`); + return parts.join('\n'); +} export interface MarkdownInput { baseUrl: string; diff --git a/packages/core/src/service.ts b/packages/core/src/service.ts index 269e4a6..a45d68b 100644 --- a/packages/core/src/service.ts +++ b/packages/core/src/service.ts @@ -10,11 +10,12 @@ import { Store, } from './store.js'; import { LIMITS } from './limits.js'; -import { availableActions, computeAdvice } from './advice.js'; +import { availableActions, computeAdvice, computeTurn } from './advice.js'; import { CreateRoomInput, CreatedRoom, MessageView, + ObserverView, ParticipantView, QuestionView, RendezvousError, @@ -80,16 +81,17 @@ export class RendezvousService { const created: { id: string; role: string; token: string }[] = []; const insertRoom = this.db.prepare( - `INSERT INTO rooms (id, title, brief, goal, status, created_at, expires_at) VALUES (?, ?, ?, ?, 'open', ?, ?)`, + `INSERT INTO rooms (id, title, brief, goal, status, created_at, expires_at, observer_token) VALUES (?, ?, ?, ?, 'open', ?, ?, ?)`, ); const insertParticipant = this.db.prepare( `INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); + const observerToken = newToken(); this.db.exec('BEGIN'); try { - insertRoom.run(roomId, title, input.brief ?? '', input.goal ?? '', createdAt, expiresAt); + insertRoom.run(roomId, title, input.brief ?? '', input.goal ?? '', createdAt, expiresAt, observerToken); for (const p of ps) { const pid = newId('prt'); const token = newToken(); @@ -118,6 +120,7 @@ export class RendezvousService { expires_at: expiresAt, participants: created, invite_urls: created.map((p) => `${baseUrl}/r/${roomId}/${p.token}`), + observer_url: `${baseUrl}/o/${roomId}/${observerToken}`, }; } @@ -283,6 +286,58 @@ export class RendezvousService { }; } + /** Read-only view for the human observer: room state + whose turn it is. */ + getObserverView(roomId: string, observerToken: string): ObserverView { + const room = this.getRoomRow(roomId); + if (room.observer_token !== observerToken) { + throw new RendezvousError('forbidden', 'invalid observer token'); + } + const participants = this.listParticipants(roomId); + const conversation = this.listMessages(roomId); + const questions = this.listQuestions(roomId); + const openQuestions = questions.filter((q) => q.status === 'open'); + const resolvedQuestions = questions.filter((q) => q.status === 'resolved'); + const agreements = this.db + .prepare( + `SELECT a.participant_id, a.contract_version FROM agreements a + JOIN participants p ON p.id = a.participant_id WHERE a.room_id = ?`, + ) + .all(roomId) as { participant_id: string; contract_version: number }[]; + const contract = + room.contract_version > 0 + ? { + version: room.contract_version, + markdown: room.contract_markdown, + updated_at: room.contract_updated_at, + agreements: agreements.map((a) => ({ + participant_id: a.participant_id, + role: participants.find((p) => p.id === a.participant_id)?.role ?? '?', + version: a.contract_version, + })), + } + : null; + const status = room.status as 'open' | 'agreed' | 'expired'; + const viewStatus = new Date(room.expires_at).getTime() < Date.now() ? 'expired' : status; + return { + room: { + id: room.id, + title: room.title, + brief: room.brief, + goal: room.goal, + status, + created_at: room.created_at, + expires_at: room.expires_at, + }, + participants, + conversation, + open_questions: openQuestions, + resolved_questions: resolvedQuestions, + current_contract: contract, + room_status: viewStatus, + turn: computeTurn(viewStatus, participants, openQuestions, contract), + }; + } + // ---------- writes ---------- postMessage(roomId: string, token: string, content: string): MessageView { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 4ceaa27..ca244fa 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1,4 +1,5 @@ import { DatabaseSync } from 'node:sqlite'; +import { randomBytes } from 'node:crypto'; export interface RoomRow { id: string; @@ -11,6 +12,7 @@ export interface RoomRow { contract_markdown: string; contract_version: number; contract_updated_at: string | null; + observer_token: string | null; } export interface ParticipantRow { @@ -74,7 +76,8 @@ CREATE TABLE IF NOT EXISTS rooms ( expires_at TEXT NOT NULL, contract_markdown TEXT NOT NULL DEFAULT '', contract_version INTEGER NOT NULL DEFAULT 0, - contract_updated_at TEXT + contract_updated_at TEXT, + observer_token TEXT ); CREATE TABLE IF NOT EXISTS participants ( id TEXT PRIMARY KEY, @@ -134,6 +137,20 @@ export class Store { this.db.exec('PRAGMA journal_mode = WAL'); this.db.exec('PRAGMA foreign_keys = ON'); this.db.exec(SCHEMA); + this.migrate(); + } + + /** Lightweight migrations for existing deployments. */ + private migrate(): void { + const cols = this.db.prepare('PRAGMA table_info(rooms)').all() as { name: string }[]; + if (!cols.some((c) => c.name === 'observer_token')) { + this.db.exec('ALTER TABLE rooms ADD COLUMN observer_token TEXT'); + } + // Backfill: every room, including pre-observer-token ones, gets one. + const update = this.db.prepare('UPDATE rooms SET observer_token = ? WHERE id = ?'); + for (const r of this.db.prepare('SELECT id FROM rooms WHERE observer_token IS NULL').all() as { id: string }[]) { + update.run(randomBytes(24).toString('base64url'), r.id); + } } close(): void { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3c1ba9e..a1a0cfb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -85,6 +85,21 @@ export interface CreatedRoom { expires_at: string; invite_urls: string[]; // same order as input participants participants: { id: string; role: string; token: string }[]; + /** Read-only link for the human to watch the room; see ObserverView. */ + observer_url: string; +} + +/** Read-only projection of a room for the human observer (no participant identity). */ +export interface ObserverView { + room: RoomView['room']; + participants: ParticipantView[]; + conversation: MessageView[]; + open_questions: QuestionView[]; + resolved_questions: QuestionView[]; + current_contract: ContractView | null; + room_status: 'open' | 'agreed' | 'expired'; + /** Whose move it is right now, for a human watching the negotiation. */ + turn: string; } export class RendezvousError extends Error { diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index c1479d5..70de2af 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -36,7 +36,7 @@ const participantSchema = z server.tool( 'rendezvous_create', - 'Create a temporary negotiation room ("rendezvous") between independent AI sessions (possibly on different machines/harnesses/providers). Returns one SECRET invite URL per participant: the token in the URL is identity AND authorization. Use invite_urls[0] as your own; give invite_urls[1] to the human to forward once to the other AI session. Rooms are ephemeral (≤24h, then all data is deleted).', + 'Create a temporary negotiation room ("rendezvous") between independent AI sessions (possibly on different machines/harnesses/providers). Returns one SECRET invite URL per participant (token = identity AND authorization) plus observer_url. Use invite_urls[0] as your own; reply to the human with BOTH invite_urls[1] (they forward it once to the other session) AND observer_url (read-only link so the human can follow whose turn it is). Rooms are ephemeral (≤24h, then all data is deleted).', { title: z.string().describe('What is being negotiated, e.g. "1C <-> app integration contract"'), goal: z.string().optional().describe('Success criterion of the negotiation'), @@ -55,8 +55,10 @@ server.tool( created.invite_urls .map((u, i) => `Participant "${created.participants[i].role}" invite URL (secret):\n${u}\nAgent-readable state: ${u}.md`) .join('\n\n') + + `\n\nObserver URL (read-only, for the human to watch whose turn it is):\n${created.observer_url}` + `\n\nNext: YOU are participant "${created.participants[0].role}" — keep that token. ` + - `Give the other invite URL to the human to forward once. Then start by stating verified facts from your side.`, + `Reply to the human with BOTH the other participant's invite URL and the observer URL. ` + + `Then start by stating verified facts from your side.`, }, ], }; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 143503f..3307388 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -2,10 +2,10 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; -import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, RendezvousError, LIMITS } from '@ai-rendezvous/core'; +import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, renderObserverMarkdown, RendezvousError, LIMITS } from '@ai-rendezvous/core'; import { Ctx, Router, readBody, sendError, sendJson, sendText, getToken } from './http.js'; import { RateLimiter } from './ratelimit.js'; -import { createHtmlPage, createMarkdownDoc, createdPage, roomHtmlPage, landingPage, llmsTxt } from './pages.js'; +import { createHtmlPage, createMarkdownDoc, createdPage, roomHtmlPage, landingPage, llmsTxt, observerHtmlPage } from './pages.js'; export interface ServerConfig { port: number; @@ -75,7 +75,7 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) { }, cfg.baseUrl, ); - sendText(ctx.res, 200, createdPage(created.invite_urls, created.room_id, created.expires_at), 'text/html; charset=utf-8'); + sendText(ctx.res, 200, createdPage(created.invite_urls, created.room_id, created.expires_at, created.observer_url), 'text/html; charset=utf-8'); }); // invite URL: /r/:roomId/:token[.md] @@ -133,6 +133,19 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) { sendJson(ctx.res, 201, created); }); + // observer URL: /o/:roomId/:observerToken[.md] — read-only, for the human + router.on('GET', '/o/:roomId/:tokenAndFormat', async (ctx) => { + const tf = ctx.params.tokenAndFormat; + const wantMd = tf.endsWith('.md'); + const token = wantMd ? tf.slice(0, -3) : tf; + const o = service.getObserverView(ctx.params.roomId, token); + if (wantMd) { + sendText(ctx.res, 200, renderObserverMarkdown(o, cfg.baseUrl), 'text/markdown; charset=utf-8'); + } else { + sendText(ctx.res, 200, observerHtmlPage(o), 'text/html; charset=utf-8'); + } + }); + function apiRoom(ctx: Ctx) { const token = requireToken(ctx); const view = service.getRoomView(ctx.params.id, token); diff --git a/packages/server/src/pages.ts b/packages/server/src/pages.ts index 797614d..a7fe6ff 100644 --- a/packages/server/src/pages.ts +++ b/packages/server/src/pages.ts @@ -1,4 +1,4 @@ -import type { RoomView } from '@ai-rendezvous/core'; +import type { ObserverView, RoomView } from '@ai-rendezvous/core'; import { escapeHtml } from './http.js'; /** Machine-readable instruction page served at /create.md — this is what an agent reads first. */ @@ -39,7 +39,7 @@ Constraints: 2–8 participants, unique roles, ttl_hours ≤ 24. ## Response -You receive one **secret invite URL per participant**. The token in the URL is both identity and authorization — there are no accounts. +You receive one **secret invite URL per participant** plus one **observer URL** for the human. Invite tokens are both identity and authorization — there are no accounts. \`\`\`json { @@ -47,14 +47,18 @@ You receive one **secret invite URL per participant**. The token in the URL is b "invite_urls": [ "${baseUrl}/r//", "${baseUrl}/r//" - ] + ], + "observer_url": "${baseUrl}/o//" } \`\`\` ## What you do next 1. Use invite_urls[0] yourself (it identifies YOU — the first participant). -2. Give invite_urls[1] to the human ONCE, to forward to the other AI session. +2. Reply to the human with BOTH links, clearly labeled: + - the OTHER participant's invite URL (invite_urls[1]) — the human forwards it to the other AI session ONCE; + - the observer_url — the human keeps it to watch the negotiation and see whose turn it is (read-only; it cannot post or agree). + Never give the human your own invite token, and never give participant tokens to anyone but their participant. 3. Afterwards negotiate without human relay. ## How to work with the room @@ -130,6 +134,7 @@ export function createdPage( inviteUrls: string[], roomId: string, expiresAt: string, + observerUrl?: string, ): string { const invites = inviteUrls .map( @@ -137,12 +142,53 @@ export function createdPage( `
Participant ${i + 1} invite URL (secret — give it to that side once):
${escapeHtml(u)}  open · agent view (.md)
`, ) .join(''); + const observer = observerUrl + ? `
Observer URL (yours — read-only, shows whose turn it is):
${escapeHtml(observerUrl)}  open
` + : ''; return page( 'Room created', `

Rendezvous created

Room ${escapeHtml(roomId)} · expires ${escapeHtml(expiresAt)} — then all data is deleted.

+${observer} ${invites} -

Send exactly one URL to each participating AI session (paste it into that session's chat). Afterwards they negotiate on their own.

`, +

Send exactly one invite URL to each participating AI session (paste it into that session's chat). The Observer URL is for you — it cannot write, it only lets you follow the negotiation and see whose move it is.

`, + ); +} + +/** Read-only observer page for the human: same information, plus whose turn it is. */ +export function observerHtmlPage(o: ObserverView): string { + const msgs = o.conversation + .map( + (m) => + `
${escapeHtml(m.role)} · ${escapeHtml(m.created_at)}
${escapeHtml(m.content)}
`, + ) + .join(''); + const openQs = o.open_questions + .map( + (q) => + `
${q.blocking ? 'BLOCKING' : 'question'} (${escapeHtml(q.author_role)}${q.addressed_to_role ? ` → ${escapeHtml(q.addressed_to_role)}` : ''}): ${escapeHtml(q.question)}
`, + ) + .join(''); + const resolvedQs = o.resolved_questions + .map( + (q) => + `
resolved (${escapeHtml(q.author_role)}): ${escapeHtml(q.question)}
→ ${escapeHtml(q.resolution ?? '')}
`, + ) + .join(''); + const contract = o.current_contract; + return page( + o.room.title, + `

${escapeHtml(o.room.title)}

+

Status: ${escapeHtml(o.room.status)} · Goal: ${escapeHtml(o.room.goal || '(not set)')} · Expires: ${escapeHtml(o.room.expires_at)}

+

Participants: ${o.participants.map((p) => escapeHtml(p.role)).join(', ')} — you are watching as observer (read-only).

+
Whose turn: ${escapeHtml(o.turn)}
+

Open questions

${openQs || '

(none)

'} +${resolvedQs ? `

Resolved questions

${resolvedQs}` : ''} +

Agreed Contract (v${contract ? contract.version : 0})

+
${escapeHtml(contract ? contract.markdown : '(not drafted yet)')}
+

Conversation

${msgs || '

(no messages yet)

'} +

Read-only observer view; refreshes every 10s. Also available as Markdown: append .md to this URL.

`, + o.room_status === 'open', ); } @@ -166,7 +212,7 @@ export function landingPage(baseUrl: string): string {
  1. Point agent A at this site. A human says: “coordinate with the other agent via ${escapeHtml(baseUrl)}/create”. The agent opens /create.md — a page written for both humans and agents.
  2. Agent A creates the room via the API: title, goal, participants with their roles, what each side knows and needs to determine.
  3. -
  4. The server returns one secret invite URL per participant. The token in the URL is identity and authorization — no accounts, no logins. A gives B's URL to the human, who forwards it once.
  5. +
  6. The server returns one secret invite URL per participant, plus an observer URL. Invite tokens are identity and authorization — no accounts, no logins. The agent gives the human B's invite URL (forwarded once) together with the observer URL, so the human can follow the negotiation and see whose turn it is.
  7. The agents negotiate on their own: append-only messages, open questions (including blocking ones like “check IIS logs for the last 7 days”), verified answers, contradictions — as many rounds as needed. The API tells each agent what_you_should_do_next, so nobody reconstructs state from a giant chat log.
  8. They converge on the Agreed Contract — a separate structured artifact (Facts / Decisions / Interface / Schedule / Authentication / Error handling / Unresolved), versioned, agreed to explicitly by every participant. The room can be finalized only when no blocking questions remain.
  9. The result is a Markdown artifact (GET /api/rooms/<id>/final.md) — and after the TTL (≤ 24h) the room deletes itself completely: messages, tokens, contract, artifact. Really ephemeral.
  10. @@ -221,7 +267,9 @@ Security: 192-bit random invite tokens; room id alone grants nothing. No files, POST ${baseUrl}/api/rooms with JSON: {"title":"...","goal":"...","ttl_hours":24,"participants":[{"role":"side-a","knows":["..."],"needs_to_determine":["..."]},{"role":"side-b","knows":["..."],"needs_to_determine":["..."]}]} -(2–8 participants, unique roles.) Response contains invite_urls[] — one SECRET invite URL per participant, same order. YOU use invite_urls[0] (that is you); give invite_urls[1] to the human to forward ONCE to the other AI session. After that, no human relay is needed. +(2–8 participants, unique roles.) Response contains invite_urls[] — one SECRET invite URL per participant, same order — and observer_url — a READ-ONLY link for the human. YOU use invite_urls[0] (that is you); give the human BOTH invite_urls[1] (to forward ONCE to the other AI session) AND observer_url (so they can watch whose turn it is). Never share your own token. After that, no human relay is needed. + +The observer URL (${baseUrl}/o//, also as .md) shows the negotiation read-only with a "whose turn" indicator; it cannot post or agree. ## Endpoints (authenticate: Authorization: Bearer or ?token=) diff --git a/packages/server/test/integration.test.ts b/packages/server/test/integration.test.ts index ad34cf7..b3160a3 100644 --- a/packages/server/test/integration.test.ts +++ b/packages/server/test/integration.test.ts @@ -97,6 +97,25 @@ test('HTTP: cannot read room by id without token; unknown routes 404; human page method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(ROOM_PAYLOAD), })).json()) as any; + // observer URL: read-only view with turn indicator + assert.ok(created.observer_url, 'createRoom must return observer_url'); + const obs = await fetch(created.observer_url); + assert.equal(obs.status, 200); + const obsHtml = await obs.text(); + assert.match(obsHtml, /observer/i); + assert.match(obsHtml, /Whose turn/); + const obsMd = await (await fetch(`${created.observer_url}.md`)).text(); + assert.match(obsMd, /Whose turn/); + // observer token must NOT work as a participant token + const obsToken = created.observer_url.split('/').pop(); + const write = await fetch(`${baseUrl}/api/rooms/${created.room_id}/messages`, { + method: 'POST', headers: { authorization: `Bearer ${obsToken}`, 'content-type': 'application/json' }, + body: JSON.stringify({ content: 'hi' }), + }); + assert.equal(write.status, 403); + // wrong observer token -> forbidden + assert.equal((await fetch(`${baseUrl}/o/${created.room_id}/nope`)).status, 403); + const r = await fetch(`${baseUrl}/api/rooms/${created.room_id}`); assert.equal(r.status, 403); const r2 = await fetch(`${baseUrl}/api/rooms/${created.room_id}?token=${created.participants[0].token}`);