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
+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}`);