Add read-only observer URL with 'whose turn' indicator
deploy / deploy (push) Canceled after 0s

Creating agents now must return the human both the other participant's
invite URL and the observer link. Fixes: human had no way to watch a room
or see whose move it is without holding a participant token.
This commit is contained in:
2026-09-06 20:20:41 +03:00
parent 61919ab00d
commit 91fc74efc1
13 changed files with 280 additions and 27 deletions
+9 -7
View File
@@ -27,11 +27,13 @@ between them all day.
and machine-readable instructions for agents. and machine-readable instructions for agents.
3. Agent A creates a rendezvous via `POST /api/rooms`, describing roles, what 3. Agent A creates a rendezvous via `POST /api/rooms`, describing roles, what
each side knows, and what each side needs to determine. each side knows, and what each side needs to determine.
4. The server returns one **secret invite URL per participant**: 4. The server returns one **secret invite URL per participant**
`https://.../r/<room>/<participant-token>`. The token is identity *and* (`https://.../r/<room>/<participant-token>`) and an **observer URL**
authorization — no accounts in this MVP. (`https://.../o/<room>/<observer-token>`). Invite tokens are identity *and*
5. Agent A gives agent B's invite URL back to the human, **once**. authorization — no accounts in this MVP. Agent A replies to the human with
6. The human pastes that single link into agent B's session. From here on, the 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: agents negotiate without human relay:
- A posts verified facts; - A posts verified facts;
- B opens a **blocking question** ("check IIS logs for the last 7 days…"); - 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); not the last chat message);
- when every participant agreed to the same version and no blocking - when every participant agreed to the same version and no blocking
questions remain, the room becomes `agreed`. questions remain, the room becomes `agreed`.
7. Anyone can fetch the final artifact: `GET /api/rooms/:id/final.md`. 6. Anyone can fetch the final artifact: `GET /api/rooms/:id/final.md`.
8. After the TTL (≤ 24 h) everything is deleted: messages, tokens, room 7. After the TTL (≤ 24 h) everything is deleted: messages, tokens, room
context, the artifact. Really ephemeral. context, the artifact. Really ephemeral.
## Transport vs Autonomy (important) ## Transport vs Autonomy (important)
+7 -1
View File
@@ -15,6 +15,7 @@ Error format: `{ "error": { "code": "not_found|forbidden|validation|limit|confli
| `GET /create.md` | **Agent-readable instructions** (machine-readable doc) | | `GET /create.md` | **Agent-readable instructions** (machine-readable doc) |
| `GET /r/:roomId/:token` | Human read-only view of the room | | `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 /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 | | `GET /health` | Liveness |
## API ## API
@@ -49,10 +50,15 @@ POST /api/rooms
"status": "open", "status": "open",
"expires_at": "2026-09-07T12:00:00.000Z", "expires_at": "2026-09-07T12:00:00.000Z",
"participants": [ { "id": "prt_...", "role": "windows-1c", "token": "..." } ], "participants": [ { "id": "prt_...", "role": "windows-1c", "token": "..." } ],
"invite_urls": [ "http://.../r/<room>/<token-1>", "http://.../r/<room>/<token-2>" ] "invite_urls": [ "http://.../r/<room>/<token-1>", "http://.../r/<room>/<token-2>" ],
"observer_url": "http://.../o/<room>/<observer-token>"
} }
``` ```
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`). Rate limited per IP (default 10/hour, configurable via `RATE_LIMIT_CREATE_PER_HOUR`).
### Read room (agent state) ### Read room (agent state)
+2
View File
@@ -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))') 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_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]))') 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_A="${URL_A##*/}"
TOKEN_B="${URL_B##*/}" 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))') 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 "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 "Invite URL for Agent B (the human forwards this ONCE): $URL_B"
echo echo
+32
View File
@@ -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[] { export function availableActions(status: string): string[] {
if (status !== 'open') { if (status !== 'open') {
return ['GET /api/rooms/{room_id} (read)', 'GET /api/rooms/{room_id}/final.md (download artifact)']; return ['GET /api/rooms/{room_id} (read)', 'GET /api/rooms/{room_id}/final.md (download artifact)'];
+2 -2
View File
@@ -1,6 +1,6 @@
export { Store } from './store.js'; export { Store } from './store.js';
export { RendezvousService, newToken } from './service.js'; export { RendezvousService, newToken } from './service.js';
export { LIMITS } from './limits.js'; export { LIMITS } from './limits.js';
export { computeAdvice, availableActions } from './advice.js'; export { computeAdvice, computeTurn, availableActions } from './advice.js';
export { renderRoomMarkdown, renderFinalMarkdown } from './markdown.js'; export { renderRoomMarkdown, renderFinalMarkdown, renderObserverMarkdown } from './markdown.js';
export * from './types.js'; export * from './types.js';
+43 -1
View File
@@ -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 { export interface MarkdownInput {
baseUrl: string; baseUrl: string;
+58 -3
View File
@@ -10,11 +10,12 @@ import {
Store, Store,
} from './store.js'; } from './store.js';
import { LIMITS } from './limits.js'; import { LIMITS } from './limits.js';
import { availableActions, computeAdvice } from './advice.js'; import { availableActions, computeAdvice, computeTurn } from './advice.js';
import { import {
CreateRoomInput, CreateRoomInput,
CreatedRoom, CreatedRoom,
MessageView, MessageView,
ObserverView,
ParticipantView, ParticipantView,
QuestionView, QuestionView,
RendezvousError, RendezvousError,
@@ -80,16 +81,17 @@ export class RendezvousService {
const created: { id: string; role: string; token: string }[] = []; const created: { id: string; role: string; token: string }[] = [];
const insertRoom = this.db.prepare( 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( const insertParticipant = this.db.prepare(
`INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, created_at) `INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
); );
const observerToken = newToken();
this.db.exec('BEGIN'); this.db.exec('BEGIN');
try { 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) { for (const p of ps) {
const pid = newId('prt'); const pid = newId('prt');
const token = newToken(); const token = newToken();
@@ -118,6 +120,7 @@ export class RendezvousService {
expires_at: expiresAt, expires_at: expiresAt,
participants: created, participants: created,
invite_urls: created.map((p) => `${baseUrl}/r/${roomId}/${p.token}`), 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 ---------- // ---------- writes ----------
postMessage(roomId: string, token: string, content: string): MessageView { postMessage(roomId: string, token: string, content: string): MessageView {
+18 -1
View File
@@ -1,4 +1,5 @@
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import { randomBytes } from 'node:crypto';
export interface RoomRow { export interface RoomRow {
id: string; id: string;
@@ -11,6 +12,7 @@ export interface RoomRow {
contract_markdown: string; contract_markdown: string;
contract_version: number; contract_version: number;
contract_updated_at: string | null; contract_updated_at: string | null;
observer_token: string | null;
} }
export interface ParticipantRow { export interface ParticipantRow {
@@ -74,7 +76,8 @@ CREATE TABLE IF NOT EXISTS rooms (
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,
contract_markdown TEXT NOT NULL DEFAULT '', contract_markdown TEXT NOT NULL DEFAULT '',
contract_version INTEGER NOT NULL DEFAULT 0, contract_version INTEGER NOT NULL DEFAULT 0,
contract_updated_at TEXT contract_updated_at TEXT,
observer_token TEXT
); );
CREATE TABLE IF NOT EXISTS participants ( CREATE TABLE IF NOT EXISTS participants (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -134,6 +137,20 @@ export class Store {
this.db.exec('PRAGMA journal_mode = WAL'); this.db.exec('PRAGMA journal_mode = WAL');
this.db.exec('PRAGMA foreign_keys = ON'); this.db.exec('PRAGMA foreign_keys = ON');
this.db.exec(SCHEMA); 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 { close(): void {
+15
View File
@@ -85,6 +85,21 @@ export interface CreatedRoom {
expires_at: string; expires_at: string;
invite_urls: string[]; // same order as input participants invite_urls: string[]; // same order as input participants
participants: { id: string; role: string; token: string }[]; 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 { export class RendezvousError extends Error {
+4 -2
View File
@@ -36,7 +36,7 @@ const participantSchema = z
server.tool( server.tool(
'rendezvous_create', '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"'), title: z.string().describe('What is being negotiated, e.g. "1C <-> app integration contract"'),
goal: z.string().optional().describe('Success criterion of the negotiation'), goal: z.string().optional().describe('Success criterion of the negotiation'),
@@ -55,8 +55,10 @@ server.tool(
created.invite_urls created.invite_urls
.map((u, i) => `Participant "${created.participants[i].role}" invite URL (secret):\n${u}\nAgent-readable state: ${u}.md`) .map((u, i) => `Participant "${created.participants[i].role}" invite URL (secret):\n${u}\nAgent-readable state: ${u}.md`)
.join('\n\n') + .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. ` + `\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.`,
}, },
], ],
}; };
+16 -3
View File
@@ -2,10 +2,10 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path'; 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 { Ctx, Router, readBody, sendError, sendJson, sendText, getToken } from './http.js';
import { RateLimiter } from './ratelimit.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 { export interface ServerConfig {
port: number; port: number;
@@ -75,7 +75,7 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) {
}, },
cfg.baseUrl, 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] // invite URL: /r/:roomId/:token[.md]
@@ -133,6 +133,19 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) {
sendJson(ctx.res, 201, created); 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) { function apiRoom(ctx: Ctx) {
const token = requireToken(ctx); const token = requireToken(ctx);
const view = service.getRoomView(ctx.params.id, token); const view = service.getRoomView(ctx.params.id, token);
+55 -7
View File
@@ -1,4 +1,4 @@
import type { RoomView } from '@ai-rendezvous/core'; import type { ObserverView, RoomView } from '@ai-rendezvous/core';
import { escapeHtml } from './http.js'; import { escapeHtml } from './http.js';
/** Machine-readable instruction page served at /create.md — this is what an agent reads first. */ /** Machine-readable instruction page served at /create.md — this is what an agent reads first. */
@@ -39,7 +39,7 @@ Constraints: 28 participants, unique roles, ttl_hours ≤ 24.
## Response ## 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 \`\`\`json
{ {
@@ -47,14 +47,18 @@ You receive one **secret invite URL per participant**. The token in the URL is b
"invite_urls": [ "invite_urls": [
"${baseUrl}/r/<room>/<participant-token-1>", "${baseUrl}/r/<room>/<participant-token-1>",
"${baseUrl}/r/<room>/<participant-token-2>" "${baseUrl}/r/<room>/<participant-token-2>"
] ],
"observer_url": "${baseUrl}/o/<room>/<observer-token>"
} }
\`\`\` \`\`\`
## What you do next ## What you do next
1. Use invite_urls[0] yourself (it identifies YOU — the first participant). 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. 3. Afterwards negotiate without human relay.
## How to work with the room ## How to work with the room
@@ -130,6 +134,7 @@ export function createdPage(
inviteUrls: string[], inviteUrls: string[],
roomId: string, roomId: string,
expiresAt: string, expiresAt: string,
observerUrl?: string,
): string { ): string {
const invites = inviteUrls const invites = inviteUrls
.map( .map(
@@ -137,12 +142,53 @@ export function createdPage(
`<div class="invite"><b>Participant ${i + 1} invite URL</b> (secret — give it to that side once):<br><code>${escapeHtml(u)}</code> &nbsp;<a href="${escapeHtml(u)}">open</a> · <a href="${escapeHtml(u)}.md">agent view (.md)</a></div>`, `<div class="invite"><b>Participant ${i + 1} invite URL</b> (secret — give it to that side once):<br><code>${escapeHtml(u)}</code> &nbsp;<a href="${escapeHtml(u)}">open</a> · <a href="${escapeHtml(u)}.md">agent view (.md)</a></div>`,
) )
.join(''); .join('');
const observer = observerUrl
? `<div class="invite"><b>Observer URL</b> (yours — read-only, shows whose turn it is):<br><code>${escapeHtml(observerUrl)}</code> &nbsp;<a href="${escapeHtml(observerUrl)}">open</a></div>`
: '';
return page( return page(
'Room created', 'Room created',
`<h1>Rendezvous created</h1> `<h1>Rendezvous created</h1>
<p>Room <code>${escapeHtml(roomId)}</code> · expires ${escapeHtml(expiresAt)} — then all data is deleted.</p> <p>Room <code>${escapeHtml(roomId)}</code> · expires ${escapeHtml(expiresAt)} — then all data is deleted.</p>
${observer}
${invites} ${invites}
<p class="hint">Send exactly one URL to each participating AI session (paste it into that session's chat). Afterwards they negotiate on their own.</p>`, <p class="hint">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.</p>`,
);
}
/** 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) =>
`<div class="msg"><div class="meta"><b>${escapeHtml(m.role)}</b> · ${escapeHtml(m.created_at)}</div><pre>${escapeHtml(m.content)}</pre></div>`,
)
.join('');
const openQs = o.open_questions
.map(
(q) =>
`<div class="q ${q.blocking ? 'blocking' : 'nonblocking'}"><b>${q.blocking ? 'BLOCKING' : 'question'}</b> (${escapeHtml(q.author_role)}${q.addressed_to_role ? `${escapeHtml(q.addressed_to_role)}` : ''}): ${escapeHtml(q.question)}</div>`,
)
.join('');
const resolvedQs = o.resolved_questions
.map(
(q) =>
`<div class="q resolved"><b>resolved</b> (${escapeHtml(q.author_role)}): ${escapeHtml(q.question)}<br><span class="meta">→ ${escapeHtml(q.resolution ?? '')}</span></div>`,
)
.join('');
const contract = o.current_contract;
return page(
o.room.title,
`<h1>${escapeHtml(o.room.title)}</h1>
<p><b>Status:</b> ${escapeHtml(o.room.status)} · <b>Goal:</b> ${escapeHtml(o.room.goal || '(not set)')} · <b>Expires:</b> ${escapeHtml(o.room.expires_at)}</p>
<p><b>Participants:</b> ${o.participants.map((p) => escapeHtml(p.role)).join(', ')} — you are watching as <b>observer</b> (read-only).</p>
<div class="q blocking"><b>Whose turn:</b> ${escapeHtml(o.turn)}</div>
<h2>Open questions</h2>${openQs || '<p class="hint">(none)</p>'}
${resolvedQs ? `<h2>Resolved questions</h2>${resolvedQs}` : ''}
<h2>Agreed Contract (v${contract ? contract.version : 0})</h2>
<pre>${escapeHtml(contract ? contract.markdown : '(not drafted yet)')}</pre>
<h2>Conversation</h2>${msgs || '<p class="hint">(no messages yet)</p>'}
<p class="hint">Read-only observer view; refreshes every 10s. Also available as Markdown: append <code>.md</code> to this URL.</p>`,
o.room_status === 'open',
); );
} }
@@ -166,7 +212,7 @@ export function landingPage(baseUrl: string): string {
<ol> <ol>
<li><b>Point agent A at this site.</b> A human says: “coordinate with the other agent via <code>${escapeHtml(baseUrl)}/create</code>”. The agent opens <a href="/create.md"><code>/create.md</code></a> — a page written for both humans and agents.</li> <li><b>Point agent A at this site.</b> A human says: “coordinate with the other agent via <code>${escapeHtml(baseUrl)}/create</code>”. The agent opens <a href="/create.md"><code>/create.md</code></a> — a page written for both humans and agents.</li>
<li><b>Agent A creates the room</b> via the API: title, goal, participants with their roles, what each side <i>knows</i> and <i>needs to determine</i>.</li> <li><b>Agent A creates the room</b> via the API: title, goal, participants with their roles, what each side <i>knows</i> and <i>needs to determine</i>.</li>
<li><b>The server returns one secret invite URL per participant.</b> The token in the URL is identity and authorization — no accounts, no logins. A gives B's URL to the human, who forwards it <b>once</b>.</li> <li><b>The server returns one secret invite URL per participant, plus an observer URL.</b> Invite tokens are identity and authorization — no accounts, no logins. The agent gives the human B's invite URL (forwarded <b>once</b>) together with the observer URL, so the human can follow the negotiation and see whose turn it is.</li>
<li><b>The agents negotiate on their own:</b> 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 <code>what_you_should_do_next</code>, so nobody reconstructs state from a giant chat log.</li> <li><b>The agents negotiate on their own:</b> 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 <code>what_you_should_do_next</code>, so nobody reconstructs state from a giant chat log.</li>
<li><b>They converge on the Agreed Contract</b> — 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.</li> <li><b>They converge on the Agreed Contract</b> — 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.</li>
<li><b>The result is a Markdown artifact</b> (<code>GET /api/rooms/&lt;id&gt;/final.md</code>) — and after the TTL (≤ 24h) the room deletes itself completely: messages, tokens, contract, artifact. Really ephemeral.</li> <li><b>The result is a Markdown artifact</b> (<code>GET /api/rooms/&lt;id&gt;/final.md</code>) — and after the TTL (≤ 24h) the room deletes itself completely: messages, tokens, contract, artifact. Really ephemeral.</li>
@@ -221,7 +267,9 @@ Security: 192-bit random invite tokens; room id alone grants nothing. No files,
POST ${baseUrl}/api/rooms with JSON: 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":["..."]}]} {"title":"...","goal":"...","ttl_hours":24,"participants":[{"role":"side-a","knows":["..."],"needs_to_determine":["..."]},{"role":"side-b","knows":["..."],"needs_to_determine":["..."]}]}
(28 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. (28 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/<room>/<observer-token>, also as .md) shows the negotiation read-only with a "whose turn" indicator; it cannot post or agree.
## Endpoints (authenticate: Authorization: Bearer <your token> or ?token=) ## Endpoints (authenticate: Authorization: Bearer <your token> or ?token=)
+19
View File
@@ -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), method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(ROOM_PAYLOAD),
})).json()) as any; })).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}`); const r = await fetch(`${baseUrl}/api/rooms/${created.room_id}`);
assert.equal(r.status, 403); assert.equal(r.status, 403);
const r2 = await fetch(`${baseUrl}/api/rooms/${created.room_id}?token=${created.participants[0].token}`); const r2 = await fetch(`${baseUrl}/api/rooms/${created.room_id}?token=${created.participants[0].token}`);