Skip to content

Web Interface and Protocol UI

What this means for you

No need to grasp the internals—just this one thing

If you want to play a game by hand, a web page opens automatically so you have somewhere to click your moves. If you'd rather your agent handle everything, the web page stays out of your way by default. You opt in or out by how you describe the session—"I'll play" vs. "you handle it."

Web startup follows the local participant's control mode. autonomous and hybrid sessions stay CLI-only unless Web is explicitly enabled; human daemon sessions default to --web auto so the operator gets a usable decision surface. An explicit --web off|auto|headless or --web-on always wins.

Architecture Overview

text
aigenora host --daemon --control-mode <autonomous|hybrid|human>
  ├─ 后台协议进程(hooks.py 运行协议生命周期)
  └─ 模式感知 Web 服务器 (127.0.0.1:random_port)
       ├─ 内置控制面板(按本地控制方式显示状态/策略/决策)
       └─ 协议业务 UI(iframe 加载 <protocol_dir>/ui/index.html)

Starting the Interface

For autonomous and hybrid, add --web-on to start the web server and open a browser:

bash
aigenora host --daemon --protocol-dir <dir> --web-on
# 输出: {"status": "hosting", "state_dir": "/path/to/session", "web_url": "http://127.0.0.1:xxxxx"}

For strict human control, Web starts automatically in daemon mode unless you explicitly turn it off:

bash
aigenora host --daemon --control-mode human --protocol-dir <dir>
aigenora join --daemon --control-mode human <invitation_id>

You can also start it manually for an existing state_dir:

bash
aigenora session web --state-dir <state_dir> [--port 8080] [--no-open]
  • --port 0 (default): Let the operating system choose a random port
  • --port N: Use the specified port
  • --no-open: Do not open a browser automatically

Built-In Control Panel

The web interface has two tabs:

Business Tab

If the Protocol provides <protocol_dir>/ui/index.html, this tab loads its custom UI in an iframe. Protocol authors can build an interface tailored to the interaction, such as a board, scoreboard, or chat window.

Where the Business UI Comes From

The local participant resolves UI in this order: existing local/built-in files, the protocol author's platform-published bundle accepted with --accept-ui, then (only when both are absent) a Host P2P snapshot accepted bilaterally with Host --share-ui and Guest --accept-host-ui. If none is usable, Business is disabled and Raw/Debug or CLI remains available.

Platform storage is the durable, reusable path. Host P2P delivery is only a session-time fallback: it is installed under the Guest's session state rather than the reusable protocol cache, and it never auto-uploads code to the platform. Old-session Host snapshots are not silently executed or redistributed later. The two Guest consents are independent and rejected by default. A hash proves snapshot integrity, not harmlessness.

Guest never opens the Host's live page. Received files are validated for paths, cross-platform case/Unicode collisions, extension, count/size, strict Base64, per-file SHA256, and manifest SHA256; Host snapshot construction also rejects symlinks/junctions escaping ui/. P2P checks each frame as it arrives and installs through staging with rollback. The files are then served from the Guest's own random localhost origin in the sandboxed iframe. A remote bundle must implement the postMessage bridge; otherwise it reports remote_ui_bridge_required and generic Web/CLI is used. UI source never changes spec.json, protocol_id, or Session Proof.

Debug Tab

The shared control panel includes:

  • Snapshot viewer: Displays the current phase, role, round, score, and Protocol name
  • Decision form: Provides structured controls for common games, such as rock/paper/scissors for RPS, along with raw JSON input
  • Strategy editor: Views, replaces, or merges strategy JSON
  • Event stream: Displays every Protocol event and detail entry in chronological order
  • Whisper messages: A floating chat bubble lets the operator communicate privately with the local Agent; messages are never sent to the peer

Mode-Specific Interface

The UI adapts to the local participant's mode. The peer's self-reported mode is informational only and never changes local behavior.

Local modeDecision surfaceAutomation controlsMissing/invalid decision
autonomousRead-only; /api/decide returns HTTP 409Strategy and Agent controls are availableAgent decides
hybridHuman may override through /api/decideStrategy and Agent controls are availableAgent/fallback decides
humanDirect game controls are primaryStrategy editor and Whisper are hiddenSession fails; no automatic fallback

GET /api/info is the source of truth for UI behavior. It exposes role, control_mode, peer_control_mode, supported_control_modes, the current decision schema, and ui_artifact provenance (local, platform, or host_p2p plus manifest hash). control_mode is runtime metadata: it does not modify spec.json, the protocol hash, or proof verification.

API Endpoints

Every endpoint binds to 127.0.0.1 and requires no authentication.

Read Endpoints

EndpointMethodDescription
/api/snapshotGETCurrent snapshot.json replacement-style state
/api/strategyGETCurrent strategy.json
/api/eventsGETAll historical events from events.jsonl as an array
/api/detailsGETAll historical entries from details.jsonl as an array
/api/whispersGETOperator Whisper history
/api/infoGETRole, local/peer control modes, supported modes, decision schema, and UI artifact provenance
/api/ui-availableGETWhether ui/index.html exists and which artifact source/manifest supplied it
/api/coach/configGETSummary of the coach backend configuration
/api/coach/dialogGETCoach conversation history

Write Endpoints

EndpointMethodDescription
/api/decidePOSTSubmit one DecisionBus decision in human or hybrid; autonomous returns HTTP 409
/api/strategy/setPOSTReplace strategy.json
/api/strategy/mergePOSTShallow-merge into strategy.json
/api/whisperPOSTAppend a Whisper that does not enter P2P
/api/whisper/ackPOSTMark a Whisper as handled
/api/operator-hintPOSTUpdate operator guidance
/api/chat/sendPOSTLocal send entry point for free/chat Protocols
/api/coach/sendPOSTSend an analysis request to the coach
/api/coach/resetPOSTClear the coach conversation history

/api/chat/send is durable across the handshake window: complete JSONL commands written before the peer is ready are delivered once the free-mode handshake completes. A partially appended final line is retried instead of discarded.

Real-Time SSE Streams

EndpointMethodDescription
/sse/streamGETServer-Sent Events stream
/api/coach/streamGETDedicated SSE stream for the coach

/sse/stream emits these event types: snapshot, strategy, event, detail, whisper, and whispers. The coach uses its own /api/coach/stream, so coach events never enter the main Session stream.

Protocol UI Development Guide

A Protocol author can add ui/index.html to the Protocol directory to provide a custom browser interface.

Minimal Example

html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>RPS Game</title>
  <style>
    body { font-family: sans-serif; color-scheme: light dark; }
  </style>
</head>
<body>
  <div id="status">Loading...</div>
  <div id="history"></div>
  <button onclick="decide('rock')">Rock</button>
  <button onclick="decide('paper')">Paper</button>
  <button onclick="decide('scissors')">Scissors</button>
  <script>
    // 1. 加载模式与初始状态
    const info = await fetch('/api/info').then(r => r.json());
    const snap = await fetch('/api/snapshot').then(r => r.json());
    document.getElementById('status').textContent = JSON.stringify(snap);
    document.querySelectorAll('button').forEach(button => {
      button.hidden = info.control_mode === 'autonomous';
    });

    // 2. 回放历史
    const details = await fetch('/api/details').then(r => r.json());
    details.forEach(d => addHistory(d));

    // 3. 订阅实时更新
    const es = new EventSource('/sse/stream');
    es.addEventListener('snapshot', e => {
      document.getElementById('status').textContent = JSON.stringify(JSON.parse(e.data));
    });
    es.addEventListener('detail', e => addHistory(JSON.parse(e.data)));

    function addHistory(d) {
      const el = document.createElement('div');
      el.textContent = `Round ${d.round}: ${d.summary || JSON.stringify(d)}`;
      document.getElementById('history').appendChild(el);
    }

    function decide(choice) {
      fetch('/api/decide', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({choice})
      });
    }
  </script>
</body>
</html>

History Replay (Important)

SSE delivers only updates emitted after subscription. On first load or after a refresh, rebuild the history from the REST endpoints before subscribing:

js
// 1. 拉 snapshot 初始化
const s = await fetch("/api/snapshot").then(r => r.json());
renderSnapshot(s);

// 2. 回放历史细节
const dts = await fetch("/api/details").then(r => r.json());
dts.forEach(handleDetail);

// 3. 订阅 SSE 接收后续增量
const es = new EventSource("/sse/stream");
es.addEventListener("snapshot", e => renderSnapshot(JSON.parse(e.data)));
es.addEventListener("detail", e => handleDetail(JSON.parse(e.data)));

An SSE subscription also sends the current complete state once, but an explicit REST fetch is clearer in initialization code and easier to debug.

Deduplication

The same detail or event may arrive once through the REST fetch and again through SSE. Deduplicate it in the frontend using a business key:

js
if (!history.some(h => h.round === payload.round)) {
  history.push(payload);
}

Security Boundary

The Protocol UI runs in a browser with the same trust boundary as the local Agent operator:

  • It cannot send P2P messages directly: Every message must pass the spec validation in hooks.py. To play a move, the UI must use POST /api/decide
  • It cannot modify spec.json: Content addressing locks the Protocol contract
  • Its distribution is separately consented: platform-author and Host-P2P UI permissions never imply each other
  • It cannot alter the control mode through the Protocol: local control mode is launcher/session metadata and is excluded from the Protocol hash
  • It cannot feed peer text to an LLM: The spec restricts business fields to enum/integer/boolean
  • strategy.json is a private local channel: It is never sent to the peer
  • It must not expose hidden peer state: decision snapshots may include the local hand or legal actions, never the opponent's hidden cards or secrets

Protocol Author Checklist

  • [ ] On page load, fetch GET /api/info and adapt controls to control_mode
  • [ ] Display ui_artifact.source_kind when provenance matters to the operator
  • [ ] On page load, render the state with GET /api/snapshot
  • [ ] On page load, rebuild history with GET /api/details
  • [ ] Subscribe to SSE for incremental updates
  • [ ] Deduplicate history by a business key
  • [ ] Submit controls through POST /api/decide
  • [ ] In human, cover every local action window (including setup, draw, pass, and forced pass) and never invent a fallback
  • [ ] Render only local private state; never infer or reveal peer hidden information
  • [ ] Use color-scheme: light dark in CSS to support light and dark themes

Whisper System

The chat bubble in the lower-right corner of the web interface lets the operator communicate privately with the local Agent. Whisper messages:

  • Never enter the P2P channel: The peer has no indication that a Whisper exists
  • Are recorded in <state_dir>/whispers.jsonl: Each entry carries a user/agent role
  • Can be read by the Agent through StrategyStore: store.read_whispers() retrieves the latest operator instructions
  • Are useful for: Adjusting tactics, changing strategy temporarily, or informing the Agent about a special situation
  • Can compile protocol-specific plans: A protocol hook may turn up to 2,000 characters of domain language into a bounded StrategyStore patch. Tank Battle supports move_to, multi-waypoint patrol, targeted attack_unit/fire, scoped units, delayed and duration-bounded stages, and force/HP triggers without invoking an LLM in the tick loop. It can also apply expiring per-tick micro overrides to selected units while the rest keep executing the macro plan. Its Business UI shows measured P2P RTT and the local micro/macro recommendation from snapshot.realtime.transport.
bash
# 通过 API 追加 whisper
curl -X POST http://127.0.0.1:xxxxx/api/whisper \
  -H 'Content-Type: application/json' \
  -d '{"role":"user","text":"下一轮出剪刀"}

Dynamic Strategies and Script Producers (v019)

This section applies to autonomous and hybrid. Strict human mode does not run strategy producers and hides these controls in the Web interface.

Fixed strategies (fixed/seq) and one-time decisions (decide) cannot express open-ended dynamic direction, such as "copy the opponent's previous move," "copy it 60% of the time and split the rest evenly," or "counter only after the opponent repeats a move twice." These instructions require the system to read the current state and calculate a decision on every turn, rather than reuse a fixed value.

Three Intervention Inputs

TypeTriggerStorage channelBest for
Persistent fixed strategy以后一直出石头StrategyStore mode=fixedA fixed value that remains active
One-time decision下一轮出石头DecisionBus future decisionA concrete value for a specific upcoming window
Dynamic strategy/script以后模仿对方上一轮StrategyStore mode=policy/scriptReading the current state and calculating a decision each turn

mode=policy (Built-In Protocol Policies)

A Protocol may provide fixed policy logic, such as mirror, counter, or repeat for RPS, through the hooks' run_policy() implementation. When a decision window opens, the engine calls the Protocol's run_policy(), which reads the opponent's previous move and calculates the counter in milliseconds without starting a subprocess.

bash
# 激活"以后克制对方上一轮"(RPS)
curl -X POST http://127.0.0.1:xxxxx/api/strategy \
  -H 'Content-Type: application/json' \
  -d '{"mode":"policy","policy":"counter_previous_opponent"}'

# 或通过 whisper
curl -X POST http://127.0.0.1:xxxxx/api/whisper \
  -H 'Content-Type: application/json' \
  -d '{"text":"以后克制对方上一轮"}'

mode=script (Script Producer, Core Capability)

This is the core mechanism for open-ended dynamic direction. An Agent or user writes a .py script, and the engine runs it in a sandbox whenever a decision window opens. The script reads the current state and applies its own logic to calculate that turn's decision.

bash
# 激活脚本策略(带参数)
curl -X POST http://127.0.0.1:xxxxx/api/strategy \
  -H 'Content-Type: application/json' \
  -d '{"mode":"script","script_id":"weighted_mirror","params":{"mirror_weight":0.6}}'

Script lookup locations, in engine order:

  1. User-local (preferred): <state_dir>/policy_scripts/<script_id>.py—an Agent can add a script at any time
  2. Bundled examples (fallback): Installed with the aigenora package and immediately available by script_id

Bundled example scripts, ready after installation:

script_idGameBehavior
weighted_mirrorRPS/CoinCopy the opponent's previous move with a weighted probability (params.mirror_weight)
counter_onceRPSCounter the opponent's previous move once
conditional_counterRPSBranch conditionally, countering only after the opponent repeats the same move twice
adaptive_bidWeak Wins AllBid adaptively: when the opponent's last bid is high, bid one less to win weakly

Script contract: JSON on stdin containing schema/context/params → JSON on stdout containing decision. A script cannot import hooks or write P2P messages, and it has a hard timeout of 1,000 ms by default before fallback. See aigenora/policy_scripts/README.md.

One-Time Strategy Intents

An instruction such as "counter the opponent's previous move next turn" combines once + strategy computation: it runs only once, but its value cannot be calculated until the decision window opens and the current state is available. The instruction is stored in InterventionIntentStore, then materialized through a one-time script run when the target window opens.

Priority and Acknowledgements

If the same decision window has both an explicit decision and a dynamically generated one, the explicit decision wins. A dynamic policy never overrides the user's immediate tactical choice. Whisper acknowledgements use granular states: strategy_active/decision_queued/intent_queued/policy_active/policy_generated/policy_failed/hint_only/rejected_finalized.

v006 P4: Isolated UI Origin and postMessage Bridge

Starting with v006, the Protocol UI iframe runs on an isolated origin with a random local port and uses a postMessage bridge to access broadcast /api/*. allow-same-origin preserves only that isolated UI server's origin; it never makes the iframe same-origin with the broadcast parent.

For Protocol Authors: Upload a UI Bundle

bash
# 注册协议时同步上传 ui/ 目录
aigenora protocol register <spec.json> --with-ui ./ui/

The client automatically:

  1. Scans <ui-dir>/ and calculates the manifest hash
  2. Uploads to staging through POST /api/v1/protocols/{id}/ui-batch
  3. Moves the bundle atomically to published through POST /api/v1/protocols/{id}/ui-finalize

Limits: 512 KB per file / 5 MB total / 100 files / extension allowlist excluding .map.

postMessage Bridge Protocol

The UI iframe must communicate through parent.postMessage, not same-origin fetch:

javascript
const PARENT_ORIGIN = new URLSearchParams(location.search).get("parent");

// 1. 握手(声明能力)
window.parent.postMessage({
  source: "aigenora-ui",
  type: "hello",
  capabilities: ["info", "snapshot", "strategy", "decide", "details", "events"]
}, PARENT_ORIGIN);  // 禁用 "*"

// 2. 请求
const id = crypto.randomUUID();
window.parent.postMessage({
  source: "aigenora-ui",
  type: "request", id,
  method: "info",  // info / snapshot / strategy / decide / details / events
  body: { ... }
}, PARENT_ORIGIN);

// 3. 监听响应 + 推送
window.addEventListener("message", ev => {
  const d = ev.data;
  if (d.source !== "aigenora-broadcast") return;
  if (ev.origin !== PARENT_ORIGIN) return;
  if (d.type === "response") { /* 匹配 id 处理 */ }
  if (d.type === "push" && d.event === "snapshot") { /* 实时更新 */ }
});

Security Model

  • iframe sandbox: allow-scripts allow-same-origin allow-popups allow-modals (without allow-forms)
  • iframe CSP: default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors <main_origin>; form-action 'none'
  • Parent-page CSP: script-src 'self' (without unsafe-inline)
  • UI file responses include X-Content-Type-Options: nosniff
  • The parent page strictly verifies event.origin === ui_origin, locking it on first use

Legacy UI Compatibility

v005-style same-origin fetch UIs already trusted as part of the local client install remain supported, including built-in Protocols such as RPS and Coin Flip, but use the legacy sandbox:

html
<iframe sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-modals"></iframe>

Detection rule: if index.html does not contain parent.postMessage, or if a .legacy-ui marker exists, the page is legacy. Legacy mode is allowed only for local/built-in UI. A platform-author or Host P2P bundle with this shape is not executed; /api/ui-available returns reason=remote_ui_bridge_required and the client falls back to Raw/Debug or CLI.

New Protocol UIs must use the postMessage bridge when distributed remotely. Same-origin fetch remains only as a compatibility path for older, locally installed UI.

UI Example

aigenora-client/src/aigenora/protocols/templates/ui-example/index.html contains a complete example UI using the postMessage bridge.