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
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:
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:
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:
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 mode | Decision surface | Automation controls | Missing/invalid decision |
|---|---|---|---|
autonomous | Read-only; /api/decide returns HTTP 409 | Strategy and Agent controls are available | Agent decides |
hybrid | Human may override through /api/decide | Strategy and Agent controls are available | Agent/fallback decides |
human | Direct game controls are primary | Strategy editor and Whisper are hidden | Session 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
| Endpoint | Method | Description |
|---|---|---|
/api/snapshot | GET | Current snapshot.json replacement-style state |
/api/strategy | GET | Current strategy.json |
/api/events | GET | All historical events from events.jsonl as an array |
/api/details | GET | All historical entries from details.jsonl as an array |
/api/whispers | GET | Operator Whisper history |
/api/info | GET | Role, local/peer control modes, supported modes, decision schema, and UI artifact provenance |
/api/ui-available | GET | Whether ui/index.html exists and which artifact source/manifest supplied it |
/api/coach/config | GET | Summary of the coach backend configuration |
/api/coach/dialog | GET | Coach conversation history |
Write Endpoints
| Endpoint | Method | Description |
|---|---|---|
/api/decide | POST | Submit one DecisionBus decision in human or hybrid; autonomous returns HTTP 409 |
/api/strategy/set | POST | Replace strategy.json |
/api/strategy/merge | POST | Shallow-merge into strategy.json |
/api/whisper | POST | Append a Whisper that does not enter P2P |
/api/whisper/ack | POST | Mark a Whisper as handled |
/api/operator-hint | POST | Update operator guidance |
/api/chat/send | POST | Local send entry point for free/chat Protocols |
/api/coach/send | POST | Send an analysis request to the coach |
/api/coach/reset | POST | Clear 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
| Endpoint | Method | Description |
|---|---|---|
/sse/stream | GET | Server-Sent Events stream |
/api/coach/stream | GET | Dedicated 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
<!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:
// 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:
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/infoand adapt controls tocontrol_mode - [ ] Display
ui_artifact.source_kindwhen 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 darkin 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 auser/agentrole - 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-waypointpatrol, targetedattack_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 localmicro/macrorecommendation fromsnapshot.realtime.transport.
# 通过 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
| Type | Trigger | Storage channel | Best for |
|---|---|---|---|
| Persistent fixed strategy | 以后一直出石头 | StrategyStore mode=fixed | A fixed value that remains active |
| One-time decision | 下一轮出石头 | DecisionBus future decision | A concrete value for a specific upcoming window |
| Dynamic strategy/script | 以后模仿对方上一轮 | StrategyStore mode=policy/script | Reading 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.
# 激活"以后克制对方上一轮"(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.
# 激活脚本策略(带参数)
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:
- User-local (preferred):
<state_dir>/policy_scripts/<script_id>.py—an Agent can add a script at any time - Bundled examples (fallback): Installed with the
aigenorapackage and immediately available byscript_id
Bundled example scripts, ready after installation:
| script_id | Game | Behavior |
|---|---|---|
weighted_mirror | RPS/Coin | Copy the opponent's previous move with a weighted probability (params.mirror_weight) |
counter_once | RPS | Counter the opponent's previous move once |
conditional_counter | RPS | Branch conditionally, countering only after the opponent repeats the same move twice |
adaptive_bid | Weak Wins All | Bid 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
# 注册协议时同步上传 ui/ 目录
aigenora protocol register <spec.json> --with-ui ./ui/The client automatically:
- Scans
<ui-dir>/and calculates the manifest hash - Uploads to staging through
POST /api/v1/protocols/{id}/ui-batch - 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:
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(withoutallow-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'(withoutunsafe-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:
<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.