Files
hermes-relay/docs/path-architecture.html

938 lines
43 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hermes-Relay · Path Architecture</title>
<style>
:root {
color-scheme: light dark;
--bg: #f4f6f7;
--panel: #ffffff;
--panel-2: #fbfcfd;
--text: #14202a;
--muted: #5d6b78;
--faint: #8593a0;
--line: #d9e0e6;
--line-soft: #e8edf1;
--code-bg: #0f1820;
--code-text: #e8f0f5;
/* Vanilla Hermes path = teal (vanilla upstream, the safe default) */
--std: #167a6e;
--std-strong: #0e5750;
--std-bg: #e6f4f0;
--std-line: #b6ddd4;
/* Relay path = violet (additive plugin power) */
--relay: #6b4fb0;
--relay-strong: #503a89;
--relay-bg: #efeafb;
--relay-line: #d3c8f0;
/* Decision / runtime nodes = amber */
--dec: #9a6313;
--dec-bg: #fdf3e0;
--dec-line: #f0dcb4;
--ok: #167a6e;
--warn: #9a6313;
--bad: #b23a48;
--radius: 12px;
--radius-sm: 8px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1318;
--panel: #151e26;
--panel-2: #111921;
--text: #e7eef3;
--muted: #9aa8b4;
--faint: #6b7884;
--line: #283541;
--line-soft: #1d2832;
--std-bg: #11302b;
--std-line: #1f5048;
--std: #4fc8b8;
--std-strong: #76d8cb;
--relay-bg: #221b3a;
--relay-line: #3d3168;
--relay: #a991e8;
--relay-strong: #c2b1f0;
--dec-bg: #2e2410;
--dec-line: #574419;
--dec: #e0aa55;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
main {
width: min(1120px, calc(100% - 32px));
margin: 0 auto;
padding: 40px 0 80px;
}
header {
display: grid;
gap: 14px;
padding: 8px 0 28px;
border-bottom: 1px solid var(--line);
margin-bottom: 36px;
}
h1, h2, h3, h4, p { margin: 0; }
h1 {
font-size: clamp(2.1rem, 5vw, 3.4rem);
line-height: 1.02;
letter-spacing: -0.02em;
}
h2 {
font-size: 1.45rem;
letter-spacing: -0.01em;
margin-bottom: 6px;
scroll-margin-top: 20px;
}
h3 { font-size: 1.05rem; margin-bottom: 4px; }
a { color: var(--std-strong); font-weight: 640; text-decoration: none; }
a:hover { text-decoration: underline; }
.eyebrow {
color: var(--std-strong);
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.lede {
color: var(--muted);
font-size: 1.08rem;
max-width: 760px;
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 11px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel);
color: var(--muted);
font-size: 0.82rem;
font-weight: 640;
}
.pill.std { border-color: var(--std-line); background: var(--std-bg); color: var(--std-strong); }
.pill.relay { border-color: var(--relay-line); background: var(--relay-bg); color: var(--relay-strong); }
section { margin: 44px 0; }
section > p { color: var(--muted); max-width: 820px; }
section > p + p { margin-top: 12px; }
.section-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 18px;
}
.section-head .num {
font-variant-numeric: tabular-nums;
font-weight: 800;
color: var(--faint);
font-size: 1.1rem;
}
/* ── TOC ─────────────────────────────────────── */
nav.toc {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 8px;
padding: 18px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
}
nav.toc a {
display: flex;
gap: 9px;
padding: 7px 9px;
border-radius: var(--radius-sm);
color: var(--text);
font-weight: 560;
font-size: 0.92rem;
}
nav.toc a:hover { background: var(--panel-2); text-decoration: none; }
nav.toc a .n { color: var(--faint); font-weight: 800; font-variant-numeric: tabular-nums; }
/* ── Generic panel ───────────────────────────── */
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px;
}
.callout {
border-left: 4px solid var(--dec);
background: var(--dec-bg);
border-radius: var(--radius-sm);
padding: 14px 16px;
margin: 18px 0;
font-size: 0.95rem;
}
.callout.std { border-left-color: var(--std); background: var(--std-bg); }
.callout.relay { border-left-color: var(--relay); background: var(--relay-bg); }
.callout strong { font-weight: 740; }
/* ── Flow nodes ──────────────────────────────── */
.flow {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 8px 0;
}
.fnode {
width: min(560px, 100%);
background: var(--panel);
border: 1.5px solid var(--line);
border-radius: var(--radius-sm);
padding: 12px 16px;
text-align: center;
}
.fnode .t { font-weight: 700; font-size: 0.97rem; }
.fnode .s { color: var(--muted); font-size: 0.84rem; margin-top: 3px; }
.fnode code { font-size: 0.82rem; }
.fnode.entry { border-color: var(--faint); background: var(--panel-2); }
.fnode.dec {
border-color: var(--dec-line);
background: var(--dec-bg);
border-radius: 999px;
width: min(520px, 100%);
}
.fnode.dec .t { color: var(--dec); }
.fnode.gateway { border-color: var(--std-line); background: var(--std-bg); }
.fnode.gateway .t { color: var(--std-strong); }
.fnode.sse { border-color: var(--std-line); }
.fnode.relay { border-color: var(--relay-line); background: var(--relay-bg); }
.fnode.relay .t { color: var(--relay-strong); }
.fnode.term { border-style: dashed; }
.conn { width: 2px; height: 22px; background: var(--line); }
.conn.arrow { position: relative; }
.conn.arrow::after {
content: "";
position: absolute;
left: 50%; bottom: -1px;
transform: translateX(-50%);
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid var(--line);
}
.branch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
width: 100%;
margin-top: 6px;
}
.branch.three { grid-template-columns: 1fr 1fr 1fr; }
.branch-col {
display: flex;
flex-direction: column;
align-items: center;
border: 1px dashed var(--line-soft);
border-radius: var(--radius);
padding: 10px 10px 14px;
background: var(--panel-2);
}
.branch-label {
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--faint);
margin-bottom: 8px;
}
.branch-label.yes { color: var(--ok); }
.branch-label.no { color: var(--bad); }
.branch-col .fnode { width: 100%; }
@media (max-width: 640px) {
.branch, .branch.three { grid-template-columns: 1fr; }
}
/* ── Two-path top diagram ────────────────────── */
.paths {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
@media (max-width: 760px) { .paths { grid-template-columns: 1fr; } }
.path-card {
border-radius: var(--radius);
padding: 18px;
border: 1.5px solid;
}
.path-card.std { border-color: var(--std-line); background: var(--std-bg); }
.path-card.relay { border-color: var(--relay-line); background: var(--relay-bg); }
.path-card h3 { display: flex; align-items: center; gap: 8px; font-size: 1.15rem; }
.path-card.std h3 { color: var(--std-strong); }
.path-card.relay h3 { color: var(--relay-strong); }
.path-card .tag {
font-size: 0.72rem; font-weight: 800; letter-spacing: 0.06em;
text-transform: uppercase; padding: 3px 8px; border-radius: 999px;
}
.path-card.std .tag { background: var(--std); color: #fff; }
.path-card.relay .tag { background: var(--relay); color: #fff; }
.path-card ul { margin: 12px 0 0; padding-left: 18px; color: var(--text); }
.path-card li { margin: 5px 0; font-size: 0.92rem; }
.path-card .req {
margin-top: 12px; font-size: 0.84rem; color: var(--muted);
border-top: 1px dashed var(--line); padding-top: 10px;
}
/* ── Tables ──────────────────────────────────── */
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; min-width: 640px; }
thead th {
text-align: left; padding: 11px 14px; background: var(--panel-2);
font-size: 0.74rem; font-weight: 800; letter-spacing: 0.05em;
text-transform: uppercase; color: var(--muted);
border-bottom: 1px solid var(--line);
}
tbody td { padding: 11px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: var(--panel-2); }
td .owner { font-weight: 740; }
td.std-c, td .std-c { color: var(--std-strong); }
td.relay-c, td .relay-c { color: var(--relay-strong); }
.dot {
display: inline-block; width: 9px; height: 9px; border-radius: 50%;
margin-right: 6px; vertical-align: middle;
}
.dot.ok { background: var(--ok); }
.dot.warn { background: var(--warn); }
.dot.bad { background: var(--bad); }
.dot.neutral { background: var(--faint); }
code {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace;
font-size: 0.86em;
background: color-mix(in srgb, var(--muted) 14%, transparent);
padding: 1px 5px; border-radius: 5px;
}
.fnode.gateway code, .fnode.dec code { background: rgba(0,0,0,0.06); }
.ref {
font-size: 0.8rem; color: var(--faint);
font-family: ui-monospace, Consolas, monospace;
}
/* ── Status-surface grid ─────────────────────── */
.surf-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
.surf {
border: 1px solid var(--line); border-radius: var(--radius);
padding: 15px; background: var(--panel);
}
.surf h4 { font-size: 0.98rem; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.surf .where { font-size: 0.76rem; color: var(--faint); font-weight: 600; }
.surf ul { margin: 9px 0 0; padding-left: 17px; }
.surf li { font-size: 0.85rem; color: var(--muted); margin: 3px 0; }
.chip {
font-size: 0.68rem; font-weight: 800; letter-spacing: 0.04em;
text-transform: uppercase; padding: 2px 7px; border-radius: 999px;
border: 1px solid var(--line);
}
.chip.good { color: var(--ok); border-color: var(--std-line); background: var(--std-bg); }
.chip.gap { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, var(--line)); background: color-mix(in srgb, var(--bad) 9%, transparent); }
.chip.part { color: var(--warn); border-color: var(--dec-line); background: var(--dec-bg); }
footer {
margin-top: 56px; padding-top: 20px; border-top: 1px solid var(--line);
color: var(--faint); font-size: 0.84rem;
}
.legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 14px 0 2px; font-size: 0.83rem; color: var(--muted); }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.swatch { width: 13px; height: 13px; border-radius: 4px; border: 1px solid var(--line); }
.swatch.std { background: var(--std-bg); border-color: var(--std-line); }
.swatch.relay { background: var(--relay-bg); border-color: var(--relay-line); }
.swatch.dec { background: var(--dec-bg); border-color: var(--dec-line); }
</style>
</head>
<body>
<main>
<header>
<span class="eyebrow">Hermes-Relay · Architecture Reference</span>
<h1>Connection Paths &amp; Chat Transport Resolution</h1>
<p class="lede">
How the Android app splits into a <strong>Vanilla Hermes</strong> path that runs on
unmodified upstream Hermes and an additive <strong>Relay</strong> plugin path —
and how, <em>within</em> Vanilla Hermes chat, it climbs a tier ladder of transports
and falls back gracefully. A separate <strong>build-flavor gate</strong> decides which
capabilities ship in the APK at all.
</p>
<div class="meta-row">
<span class="pill std">Vanilla Hermes = vanilla upstream only</span>
<span class="pill relay">Relay = optional plugin</span>
<span class="pill" style="border-color:var(--dec-line);background:var(--dec-bg);color:var(--dec)">Sideload = Device Control gate</span>
<span class="pill">v1.0.0 · updated 2026-06-18</span>
</div>
<div class="legend">
<span><span class="swatch std"></span> Vanilla Hermes / upstream surface</span>
<span><span class="swatch relay"></span> Relay plugin surface</span>
<span><span class="swatch dec"></span> Runtime decision</span>
</div>
</header>
<nav class="toc" aria-label="Contents">
<a href="#model"><span class="n">1</span> Paths &amp; the flavor gate</a>
<a href="#network"><span class="n">2</span> Four network endpoints</a>
<a href="#tiers"><span class="n">3</span> Chat transport tiers</a>
<a href="#resolve"><span class="n">4</span> How a path is chosen (flowchart)</a>
<a href="#inputs"><span class="n">5</span> Decision inputs: availability + caps</a>
<a href="#matrix"><span class="n">6</span> Feature → path matrix</a>
<a href="#auth"><span class="n">7</span> Auth model per path</a>
<a href="#surfaces"><span class="n">8</span> Where status is surfaced</a>
</nav>
<!-- ───────────────────────── 1 ───────────────────────── -->
<section id="model">
<div class="section-head"><span class="num">01</span><h2>The two paths &amp; the flavor gate</h2></div>
<p>
Every capability in the app belongs to exactly one of two paths. The dividing
line is a hard product rule: the Vanilla Hermes path must work against
<strong>unmodified upstream hermes-agent</strong> — the app ships on Google Play to
servers we don't control. Anything needing server-side code lives behind the
opt-in Relay plugin (or goes upstream as a PR with graceful degradation).
</p>
<div class="paths" style="margin-top:18px">
<div class="path-card std">
<h3>🟢 Vanilla Hermes <span class="tag">vanilla upstream</span></h3>
<p style="color:var(--std-strong);font-size:0.9rem;margin-top:4px">
No pairing. Works the moment you point at a Hermes server + dashboard.
</p>
<ul>
<li><strong>Chat</strong> — gateway WS, then API-server SSE (tiered, see §3)</li>
<li><strong>Manage</strong> — config, profiles, model, env, MCP (dashboard)</li>
<li><strong>Vanilla Hermes voice</strong> — dashboard <code>/api/audio/*</code></li>
<li><strong>Sessions / history</strong> — native <code>/api/sessions</code></li>
</ul>
<div class="req">Auth: API-server bearer key + dashboard cookie (Manage sign-in).</div>
</div>
<div class="path-card relay">
<h3>🟣 Relay <span class="tag">optional plugin</span></h3>
<p style="color:var(--relay-strong);font-size:0.9rem;margin-top:4px">
Requires pairing a Relay. Purely additive power features.
</p>
<ul>
<li><strong>Terminal</strong> — server shell over WSS</li>
<li><strong>Bridge</strong> — phone/device control (sideload)</li>
<li><strong>Enhanced / relay voice</strong> — streaming TTS, realtime agent</li>
<li><strong>Desktop tools</strong> · <strong>notification companion</strong> · <strong>remote access</strong></li>
</ul>
<div class="req">Auth: paired Relay session token (in-memory; re-pair after relay restart).</div>
</div>
</div>
<div class="callout std">
<strong>Chat never crosses into Relay.</strong> Vanilla Hermes chat "mixes" only
<em>within</em> the Vanilla Hermes path — between the gateway WS transport and the
API-server SSE transports. The Relay plugin adds terminal, bridge, and voice
surfaces, but it does <em>not</em> carry Vanilla Hermes chat. That keeps the Play-Store
default fully functional with zero plugin installed.
</div>
<h3 style="margin-top:36px">The third axis — build flavor (capability gate)</h3>
<p style="margin-top:6px">
Path (Vanilla Hermes vs Relay) is about <em>which server surface</em> you talk to. Flavor is a
different axis entirely: a capability ceiling <strong>compiled into the APK</strong>,
independent of any server or pairing. Both flavors ship the full Relay client — only
<strong>sideload</strong> compiles in phone <strong>Device Control</strong> (the
AccessibilityService "hands"). The active track shows in Settings → About as a badge.
</p>
<div class="paths" style="margin-top:16px">
<div class="path-card" style="border-color:var(--line);background:var(--panel)">
<h3 style="color:var(--text)">googlePlay <span class="tag" style="background:var(--faint);color:#fff">Bridge Core</span></h3>
<p style="font-size:0.9rem;color:var(--muted);margin-top:4px">
Conservative track. No phone Device Control surface.
</p>
<ul>
<li>Full Vanilla Hermes path — chat, Manage, voice</li>
<li>Relay: terminal, relay voice, notification companion, media, session grants</li>
<li><strong>No</strong> screen reading, taps, typing, screenshots, overlays, or unattended control</li>
</ul>
<div class="req">Gated <code>android_*</code> tools return a <code>sideload-only</code> 403 instead of crashing; the Bridge phone-control screen isn't present.</div>
</div>
<div class="path-card relay">
<h3>sideload <span class="tag">full Device Control</span></h3>
<p style="color:var(--relay-strong);font-size:0.9rem;margin-top:4px">
Full-capability track. Adds the AccessibilityService bridge.
</p>
<ul>
<li>Everything googlePlay ships, plus…</li>
<li>Device Control hands: tap · type · navigate · screen-read · screenshot · overlay</li>
<li>Unattended access · Tier-C tools (call / SMS / contacts / location)</li>
</ul>
<div class="req">Device Control tiers 1–6 (baseline → screen context → voice-first → vision-first → safety rails → future) are all sideload-gated.</div>
</div>
</div>
<div class="callout relay">
<strong>Bridge is two-layered.</strong> Relay pairing unlocks the bridge <em>channel</em>;
the sideload flavor unlocks the Device Control <em>hands</em> within it. A paired Relay on a
googlePlay build still cannot tap or type — the capability simply isn't compiled in.
<span class="ref">BuildFlavor · data/FeatureFlags.kt:92</span>
</div>
</section>
<!-- ───────────────────────── 2 ───────────────────────── -->
<section id="network">
<div class="section-head"><span class="num">02</span><h2>Four network endpoints</h2></div>
<p>The phone talks to up to four distinct surfaces. The first three are upstream; the fourth is ours.</p>
<div class="table-wrap" style="margin-top:16px">
<table>
<thead>
<tr><th>Transport</th><th>Target</th><th>Owner</th><th>Carries</th></tr>
</thead>
<tbody>
<tr>
<td><span class="dot ok"></span><strong>WS</strong></td>
<td>Hermes dashboard <code>:9119</code> <code>/api/ws</code></td>
<td class="std-c owner">Upstream (tui_gateway)</td>
<td>Vanilla Hermes gateway chat · <strong>live</strong> thinking/reasoning deltas</td>
</tr>
<tr>
<td><span class="dot ok"></span><strong>HTTP/SSE</strong></td>
<td>Hermes API server <code>:8642</code></td>
<td class="std-c owner">Upstream (api_server)</td>
<td>Vanilla Hermes chat fallback · sessions · runs · capabilities</td>
</tr>
<tr>
<td><span class="dot ok"></span><strong>HTTP</strong></td>
<td>Hermes dashboard <code>:9119</code> <code>/api/*</code></td>
<td class="std-c owner">Upstream (web_server)</td>
<td>Manage · Vanilla Hermes voice <code>/api/audio/*</code> · auth/ws-ticket</td>
</tr>
<tr>
<td><span class="dot" style="background:var(--relay)"></span><strong>WSS/HTTP</strong></td>
<td>Relay plugin/server <code>:8767</code></td>
<td class="relay-c owner">Hermes-Relay plugin</td>
<td>Bridge · terminal · relay voice · desktop tools · media</td>
</tr>
</tbody>
</table>
</div>
<p class="ref" style="margin-top:10px">
API-server bearer auth and dashboard cookie auth are <em>separate</em>. See
<a href="upstream-surface-matrix.md">docs/upstream-surface-matrix.md</a> for the full route-ownership contract.
</p>
</section>
<!-- ───────────────────────── 3 ───────────────────────── -->
<section id="tiers">
<div class="section-head"><span class="num">03</span><h2>Chat transport tiers</h2></div>
<p>
Vanilla Hermes chat has four transports, ordered best-to-fallback. The resolver
prefers the gateway (it's the only surface with live reasoning), then degrades
down the SSE ladder based on what the server actually advertises.
</p>
<div class="table-wrap" style="margin-top:16px">
<table>
<thead>
<tr><th>#</th><th>Tier</th><th>Endpoint</th><th>Why prefer it</th><th>Tool / reasoning fidelity</th></tr>
</thead>
<tbody>
<tr>
<td><strong>1</strong></td>
<td class="std-c"><strong>Gateway WS</strong></td>
<td><code>/api/ws</code> (dashboard)</td>
<td>Only surface with <strong>live</strong> <code>reasoning.delta</code> / <code>thinking.delta</code>; full byte-upload attach</td>
<td>Structured tool events + live thinking</td>
</tr>
<tr>
<td><strong>2</strong></td>
<td><strong>Sessions SSE</strong></td>
<td><code>/api/sessions/{id}/chat/stream</code></td>
<td>Native upstream, session-persisted; preferred SSE when probed present</td>
<td>Structured SSE events; reasoning post-hoc</td>
</tr>
<tr>
<td><strong>3</strong></td>
<td><strong>Completions SSE</strong></td>
<td><code>/v1/chat/completions</code></td>
<td>OpenAI-compatible; always-available stateless fallback</td>
<td>Inline markdown tool annotations only</td>
</tr>
<tr>
<td><strong>4</strong></td>
<td><strong>Runs SSE</strong></td>
<td><code>/v1/runs</code> → <code>/events</code></td>
<td>Structured run lifecycle when sessions absent but runs advertised</td>
<td>Structured tool events (<code>tool.started/completed</code>)</td>
</tr>
</tbody>
</table>
</div>
<div class="callout">
<strong>Voice turns are SSE-only.</strong> The gateway's <code>prompt.submit</code> RPC
has no system-message slot, so per-turn ephemeral instructions (voice interface
context) can't ride it. A "gateway" preference is force-downgraded to SSE for
those turns. <span class="ref">ChatViewModel.send() · effectiveEndpoint</span>
</div>
</section>
<!-- ───────────────────────── 4 ───────────────────────── -->
<section id="resolve">
<div class="section-head"><span class="num">04</span><h2>How a path is chosen — the flowchart</h2></div>
<p>
Resolution happens in <strong>two moments</strong>. First, at connect/probe time, a pure
function picks a <em>preference</em>. Second, at send time, <code>ChatViewModel</code>
re-checks live state and can still downgrade to SSE. This is why a turn whose
preference reads "gateway" can quietly complete over SSE.
</p>
<h3 style="margin-top:24px;color:var(--dec)">Phase A · Connect-time preference</h3>
<p class="ref"><code>resolveStreamingEndpointPreference(preference, gateway, capabilities)</code> — GatewayModels.kt:63</p>
<div class="flow panel" style="margin-top:12px">
<div class="fnode entry">
<div class="t">streamingEndpoint preference</div>
<div class="s">user setting: <code>"auto"</code> (default) · or a manual pin</div>
</div>
<div class="conn arrow"></div>
<div class="fnode dec">
<div class="t">Is the preference a manual pin?</div>
<div class="s"><code>"gateway"</code> / <code>"sessions"</code> / <code>"completions"</code> / <code>"runs"</code></div>
</div>
<div class="conn"></div>
<div class="branch">
<div class="branch-col">
<span class="branch-label yes">Yes — manual</span>
<div class="fnode term">
<div class="t">Use it verbatim</div>
<div class="s">Manual selection owns a new chat; existing bindings do not migrate.</div>
</div>
</div>
<div class="branch-col">
<span class="branch-label no">No — "auto"</span>
<div class="fnode dec" style="width:100%">
<div class="t">Saved connection owner?</div>
</div>
<div class="conn"></div>
<div class="branch">
<div class="branch-col">
<span class="branch-label yes">Standard</span>
<div class="fnode gateway"><div class="t">→ "gateway"</div></div>
</div>
<div class="branch-col">
<span class="branch-label no">API-only</span>
<div class="fnode sse">
<div class="t">capabilities<br>.preferredChatEndpoint()</div>
<div class="s">sessions › completions › runs</div>
</div>
</div>
</div>
</div>
</div>
</div>
<h3 style="margin-top:32px;color:var(--dec)">Phase B · Send-time dispatch</h3>
<p class="ref"><code>ChatViewModel.send()</code> — runtime re-check &amp; fallback wiring</p>
<div class="flow panel" style="margin-top:12px">
<div class="fnode entry">
<div class="t">User sends a message</div>
<div class="s">effectiveEndpoint = resolved preference</div>
</div>
<div class="conn arrow"></div>
<div class="fnode dec">
<div class="t">Bound owner == "gateway"?</div>
</div>
<div class="conn"></div>
<div class="branch">
<div class="branch-col">
<span class="branch-label no">Direct API owner</span>
<div class="fnode sse"><div class="t">dispatchSse(endpoint)</div></div>
</div>
<div class="branch-col">
<span class="branch-label yes">Gateway owner</span>
<div class="fnode dec" style="width:100%"><div class="t">gateway client live?</div></div>
<div class="conn"></div>
<div class="branch">
<div class="branch-col">
<span class="branch-label no">null</span>
<div class="fnode term"><div class="t">Preserve + Retry</div><div class="s">sign in or reconnect; no owner change</div></div>
</div>
<div class="branch-col">
<span class="branch-label yes">yes</span>
<div class="fnode gateway"><div class="t">gateway.sendTurn()</div></div>
</div>
</div>
</div>
</div>
<div class="conn arrow" style="margin-top:6px"></div>
<div class="fnode dec">
<div class="t">gateway.sendTurn → outcome</div>
</div>
<div class="conn"></div>
<div class="branch">
<div class="branch-col">
<span class="branch-label yes">streams OK</span>
<div class="fnode gateway term">
<div class="t">Gateway turn</div>
<div class="s">live thinking + structured tools</div>
</div>
</div>
<div class="branch-col">
<span class="branch-label no">onPreflightFailure</span>
<div class="fnode term">
<div class="t">Preserve + Retry</div>
<div class="s">nothing started server-side; conversation stays Gateway-owned</div>
</div>
</div>
</div>
</div>
<div class="callout std">
<strong>Transport affinity.</strong> Gateway and Direct API sessions live in
different stores. Sign-in expiry or route loss never authorizes Android to
resubmit the turn through another owner.
</div>
</section>
<!-- ───────────────────────── 5 ───────────────────────── -->
<section id="inputs">
<div class="section-head"><span class="num">05</span><h2>Owner, readiness, and capabilities</h2></div>
<p>Saved connection state chooses the owner. Availability reports whether that owner is ready; Direct API capabilities choose an endpoint only inside an API-owned conversation.</p>
<div class="paths" style="margin-top:16px">
<div class="panel">
<h3 style="color:var(--std-strong)">GatewayAvailability</h3>
<p class="ref">GatewayModels.kt:22 · set by the dashboard <code>/api/status</code> + <code>/api/auth/me</code> probe</p>
<ul style="padding-left:18px;margin-top:10px">
<li><span class="dot neutral"></span><strong>Unknown</strong> — no probe yet (startup / connection switch)</li>
<li><span class="dot ok"></span><strong>Ready</strong> — reachable + authenticated (or no auth) → Gateway can send</li>
<li><span class="dot warn"></span><strong>SignInRequired</strong> — reachable but gated; Manage sign-in unlocks it</li>
<li><span class="dot bad"></span><strong>Unreachable</strong> — <code>/api/status</code> didn't answer</li>
<li><span class="dot bad"></span><strong>Unsupported</strong> — <em>sticky</em>: WS upgrade/ticket got 404/403 (build predates embedded chat)</li>
</ul>
</div>
<div class="panel">
<h3>ServerCapabilities</h3>
<p class="ref">HermesApiClient.probeCapabilities() · probe order below</p>
<div class="flow" style="padding-top:4px">
<div class="fnode" style="width:100%"><div class="t"><code>GET /health</code></div><div class="s">unreachable → DISCONNECTED, stop</div></div>
<div class="conn arrow"></div>
<div class="fnode" style="width:100%"><div class="t"><code>GET /v1/capabilities</code></div><div class="s">present → parse &amp; return (authoritative)</div></div>
<div class="conn arrow"></div>
<div class="fnode" style="width:100%">
<div class="t">HEAD probes (fallback)</div>
<div class="s"><code>/api/sessions</code> · <code>…/chat/stream</code> · <code>/v1/chat/completions</code> · <code>/v1/runs</code></div>
</div>
</div>
<p style="font-size:0.85rem;color:var(--muted);margin-top:8px">
<code>preferredChatEndpoint()</code> = <code>sessionsChatStream ? "sessions" : portable ? "completions" : runs ? "runs" : "sessions"</code>
</p>
</div>
</div>
</section>
<!-- ───────────────────────── 6 ───────────────────────── -->
<section id="matrix">
<div class="section-head"><span class="num">06</span><h2>Feature → path matrix</h2></div>
<p>Which path unlocks each feature, and what it degrades to when that path is unavailable.</p>
<div class="table-wrap" style="margin-top:16px">
<table>
<thead>
<tr><th>Feature</th><th>Path</th><th>Surface</th><th>Degrades to</th></tr>
</thead>
<tbody>
<tr><td><strong>Chat (live thinking)</strong></td><td class="std-c">Vanilla Hermes</td><td>Gateway WS</td><td>Sign in or retry on the same owner</td></tr>
<tr><td><strong>Direct API chat</strong></td><td class="std-c">Vanilla Hermes</td><td>API-server SSE</td><td>Inline-annotation parser</td></tr>
<tr><td><strong>Session history / CRUD</strong></td><td class="std-c">Vanilla Hermes</td><td><code>/api/sessions</code></td><td>Stateless completions (no persistence)</td></tr>
<tr><td><strong>Manage</strong> (config/profiles/model/env/MCP)</td><td class="std-c">Vanilla Hermes</td><td>Dashboard <code>/api/*</code></td><td>— (hidden if dashboard down)</td></tr>
<tr><td><strong>Vanilla Hermes voice</strong> (STT/TTS)</td><td class="std-c">Vanilla Hermes</td><td>Dashboard <code>/api/audio/*</code></td><td>Relay voice if paired (Auto route)</td></tr>
<tr><td><strong>Terminal</strong></td><td class="relay-c">Relay</td><td>WSS <code>terminal.*</code></td><td>Unavailable — "Pair Relay"</td></tr>
<tr><td><strong>Bridge — Device Control</strong> (tap/type/read/screenshot/overlay)</td><td class="relay-c">Relay + <strong>sideload</strong></td><td>AccessibilityService + WSS <code>bridge.command</code></td><td>googlePlay: surface absent</td></tr>
<tr><td><strong>Unattended access</strong></td><td class="relay-c">Relay + <strong>sideload</strong></td><td>Bridge unattended mode</td><td>googlePlay: not shipped</td></tr>
<tr><td><strong>Tier-C tools</strong> (call / SMS / contacts / location)</td><td class="relay-c">Relay + <strong>sideload</strong></td><td><code>android_*</code> tools</td><td>googlePlay: <code>403 sideload_only</code></td></tr>
<tr><td><strong>Enhanced / streaming voice</strong></td><td class="relay-c">Relay</td><td><code>/voice/output/*</code>, <code>/voice/synthesize</code></td><td>Vanilla Hermes voice (basic)</td></tr>
<tr><td><strong>Realtime agent voice</strong></td><td class="relay-c">Relay</td><td><code>/voice/realtime-agent/*</code></td><td>Unavailable (experimental)</td></tr>
<tr><td><strong>Notification companion</strong></td><td class="relay-c">Relay</td><td>WSS notifications channel</td><td>Unavailable</td></tr>
<tr><td><strong>Desktop tools</strong></td><td class="relay-c">Relay</td><td>WSS <code>desktop.command</code></td><td>Unavailable</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ───────────────────────── 7 ───────────────────────── -->
<section id="auth">
<div class="section-head"><span class="num">07</span><h2>Auth model per path</h2></div>
<p>Three independent credential systems. None substitutes for another — this is why "connected" is not one boolean.</p>
<div class="table-wrap" style="margin-top:16px">
<table>
<thead>
<tr><th>Path</th><th>Credential</th><th>Source</th><th>Lifetime</th></tr>
</thead>
<tbody>
<tr>
<td class="std-c owner">Gateway WS</td>
<td>Dashboard cookie + WS ticket</td>
<td>Manage sign-in → <code>/api/auth/ws-ticket</code></td>
<td>Ticket 30s, one-use (fresh per connect); cookie = session</td>
</tr>
<tr>
<td class="std-c owner">API-server SSE</td>
<td>Bearer API key</td>
<td>Stored per connection (<code>SessionTokenStore</code>)</td>
<td>Until rotated</td>
</tr>
<tr>
<td class="std-c owner">Dashboard / Manage / voice</td>
<td>Dashboard cookie</td>
<td>Manage sign-in (password / Nous OIDC)</td>
<td>Server session; wiped on dashboard restart</td>
</tr>
<tr>
<td class="relay-c owner">Relay plugin</td>
<td>Paired session token</td>
<td>QR / 6-char pairing → <code>auth.ok</code></td>
<td><strong>Persisted</strong> — survives relay restart (sessions file + trusted-device refresh); no re-pair needed</td>
</tr>
</tbody>
</table>
</div>
<div class="callout relay">
<strong>Do not proxy dashboard auth or admin APIs over the Relay.</strong> Manage and
Vanilla Hermes voice ride the dashboard surface directly with the dashboard cookie.
The Relay carries only Relay-owned capabilities.
</div>
</section>
<!-- ───────────────────────── 8 ───────────────────────── -->
<section id="surfaces">
<div class="section-head"><span class="num">08</span><h2>Where status is surfaced today</h2></div>
<p>
Inventory of the surfaces a user can read connection / path / feature health from.
Status coverage is broad; the gap is a single consolidated diagnostic view and any
visibility into the <em>capability snapshot</em> that actually drives the decision.
</p>
<div class="surf-grid" style="margin-top:18px">
<div class="surf">
<h4>Connections settings — active card <span class="chip good">rich</span></h4>
<div class="where">ConnectionsSettingsScreen · ActiveConnectionSections</div>
<ul>
<li>API / Dashboard / Voice / Relay / Terminal / Secure-proxy status rows</li>
<li>Route section: per-endpoint probe outcome (LAN/Tailscale/Public)</li>
<li>Transport security badge · keystore · relay session count</li>
</ul>
</div>
<div class="surf">
<h4>Settings root — exception pills <span class="chip good">good</span></h4>
<div class="where">SettingsScreen · ActiveAgentCard</div>
<ul>
<li>"connection · model · personality" subtitle</li>
<li>Pills appear only when action needed (API offline, Dashboard sign-in, Relay stale, Plugin offline)</li>
</ul>
</div>
<div class="surf">
<h4>Chat / Terminal headers <span class="chip good">good</span></h4>
<div class="where">ConnectionStatusBadge · RelayUiState</div>
<ul>
<li>4-state animated dot (Connected/Connecting/Probing/Disconnected)</li>
<li>Relay row: Connected · Stale · Expired + endpoint role ("· Tailscale")</li>
</ul>
</div>
<div class="surf">
<h4>Voice settings <span class="chip good">good</span></h4>
<div class="where">VoiceSettingsScreen</div>
<ul>
<li>Engine · STT/TTS route (Auto/Vanilla Hermes/Relay) each Ready/Offline/Checking</li>
<li>Render path: streaming vs basic synthesize</li>
</ul>
</div>
<div class="surf">
<h4>Bridge <span class="chip good">good</span></h4>
<div class="where">BridgeScreen · BridgeStatusCard</div>
<ul>
<li>Relay-paired vs "Relay not connected" warning</li>
<li>Device / battery / screen / current-app / a11y telemetry</li>
</ul>
</div>
<div class="surf">
<h4>Info sheets (tap-to-detail) <span class="chip part">fragmented</span></h4>
<div class="where">ApiServer / Relay / Session InfoSheet + DiagnosticsLogPanel</div>
<ul>
<li>Per-surface URL, reachability, recent diagnostic-log entries (6–8)</li>
<li>Three separate sheets — no single combined view</li>
</ul>
</div>
</div>
<h3 style="margin-top:28px">Diagnostic gaps vs. <code>hermes relay doctor</code></h3>
<div class="table-wrap" style="margin-top:12px">
<table>
<thead>
<tr><th>What's missing</th><th>Today</th><th>Status</th></tr>
</thead>
<tbody>
<tr><td>Active transport "you are on X" indicator</td><td>Preference resolved silently; user can't see gateway vs which SSE tier is live</td><td><span class="chip gap">gap</span></td></tr>
<tr><td>Capability snapshot (sessions/completions/runs/health)</td><td>Probed and used internally; <strong>never shown</strong></td><td><span class="chip gap">gap</span></td></tr>
<tr><td>"Why auto picked X" explanation</td><td>No surfaced reasoning (e.g. "gateway SignInRequired → sessions")</td><td><span class="chip gap">gap</span></td></tr>
<tr><td>Turn latency (TTFE / TTFT / done)</td><td><code>TurnLatencyTracer</code> → <strong>logcat only</strong></td><td><span class="chip gap">gap</span></td></tr>
<tr><td>Unified single-screen health view</td><td>Scattered across 3 info sheets + 2 settings screens</td><td><span class="chip part">partial</span></td></tr>
<tr><td>Per-route reachability + reason</td><td>Shown in Routes section, but not all failure reasons</td><td><span class="chip part">partial</span></td></tr>
</tbody>
</table>
</div>
<div class="callout">
<strong>Recommendation.</strong> A single <em>Connection Diagnostics</em> screen (the in-app
analogue of <code>hermes relay doctor --json</code>) would consolidate: active chat
transport + tier, the capability probe table with the resolver's reasoning, per-route
probe outcomes, auth state for all three credential systems, and the last turn's latency
marks. Everything it needs already exists in <code>ServerCapabilities</code>,
<code>GatewayAvailability</code>, <code>DiagnosticsLog</code>, and <code>TurnLatencyTracer</code> —
it's an aggregation surface, not new plumbing.
</div>
</section>
<footer>
<p>
Hermes-Relay path architecture reference. Source of truth for transport resolution:
<code>network/upstream/GatewayModels.kt</code>, <code>HermesApiClient.kt</code>,
<code>viewmodel/ChatViewModel.kt</code>, <code>viewmodel/connection/UpstreamTransportController.kt</code>.
Route ownership: <a href="upstream-surface-matrix.md">upstream-surface-matrix.md</a>.
Always verify upstream endpoints against <code>gateway/platforms/api_server.py</code>.
</p>
</footer>
</main>
</body>
</html>