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
+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[] {
if (status !== 'open') {
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 { 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';
+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 {
baseUrl: string;
+58 -3
View File
@@ -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 {
+18 -1
View File
@@ -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 {
+15
View File
@@ -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 {
+4 -2
View File
@@ -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.`,
},
],
};
+16 -3
View File
@@ -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);
+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';
/** 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
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/<room>/<participant-token-1>",
"${baseUrl}/r/<room>/<participant-token-2>"
]
],
"observer_url": "${baseUrl}/o/<room>/<observer-token>"
}
\`\`\`
## 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(
`<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('');
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(
'Room created',
`<h1>Rendezvous created</h1>
<p>Room <code>${escapeHtml(roomId)}</code> · expires ${escapeHtml(expiresAt)} — then all data is deleted.</p>
${observer}
${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>
<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>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>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>
@@ -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":["..."]}]}
(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=)
+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),
})).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}`);