docs(user-docs): deploy refreshed site

This commit is contained in:
Bailey Dixon
2026-06-12 16:10:23 -04:00
parent ed9dafe571
commit 5c7d6490f1
40 changed files with 1890 additions and 790 deletions
+20 -14
View File
@@ -3,7 +3,7 @@ import { defineConfig } from 'vitepress'
export default defineConfig({
base: '/hermes-relay/',
title: 'Hermes-Relay',
description: 'Hermes-Relay — Android phone control + desktop terminal client for the Hermes agent platform',
description: 'Hermes-Relay — the Android companion and remote-hands CLI for your Hermes agent. Runs on your machine; lives on your devices.',
head: [
// Favicon — base path is NOT auto-applied to head entries in VitePress,
@@ -17,25 +17,25 @@ export default defineConfig({
// Open Graph — crawlers (Facebook, Messenger, Slack, Discord, LinkedIn) need absolute URLs
['meta', { property: 'og:type', content: 'website' }],
['meta', { property: 'og:site_name', content: 'Hermes-Relay' }],
['meta', { property: 'og:title', content: 'Hermes-Relay — Phone control + desktop terminal for Hermes Agent' }],
['meta', { property: 'og:description', content: 'Two surfaces for one Hermes agent: a native Android app for phone control + a desktop CLI that lets you use a server-deployed Hermes from your laptop as if it were local.' }],
['meta', { property: 'og:title', content: 'Hermes-Relay — give your Hermes agent hands' }],
['meta', { property: 'og:description', content: 'Runs on your machine. Lives on your devices. A native Android companion for chat, voice, and phone control — plus a single-binary CLI the agent uses to work on any machine you pair.' }],
['meta', { property: 'og:url', content: 'https://codename-11.github.io/hermes-relay/' }],
['meta', { property: 'og:image', content: 'https://codename-11.github.io/hermes-relay/og-image.png' }],
['meta', { property: 'og:image:secure_url', content: 'https://codename-11.github.io/hermes-relay/og-image.png' }],
['meta', { property: 'og:image:type', content: 'image/png' }],
['meta', { property: 'og:image:width', content: '1024' }],
['meta', { property: 'og:image:height', content: '500' }],
['meta', { property: 'og:image:alt', content: 'Hermes-Relay — Phone control + desktop terminal for Hermes Agent.' }],
['meta', { property: 'og:image:alt', content: 'Hermes-Relay — give your Hermes agent hands.' }],
// Twitter / X Card
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
['meta', { name: 'twitter:title', content: 'Hermes-Relay — Phone control + desktop terminal for Hermes Agent' }],
['meta', { name: 'twitter:description', content: 'Android remote-control app + desktop CLI for the Hermes agent platform — one pair, two surfaces.' }],
['meta', { name: 'twitter:title', content: 'Hermes-Relay — give your Hermes agent hands' }],
['meta', { name: 'twitter:description', content: 'Android companion for chat, voice, and phone control + a single-binary CLI that gives your Hermes agent hands on any machine. One pair, every device.' }],
['meta', { name: 'twitter:image', content: 'https://codename-11.github.io/hermes-relay/og-image.png' }],
['meta', { name: 'twitter:image:alt', content: 'Hermes-Relay — Phone control + desktop terminal for Hermes Agent.' }],
['meta', { name: 'twitter:image:alt', content: 'Hermes-Relay — give your Hermes agent hands.' }],
// Theme
['meta', { name: 'theme-color', content: '#000000' }],
// Theme — RelayRefresh.Background
['meta', { name: 'theme-color', content: '#08090D' }],
// Fonts — Space Grotesk + Space Mono (shared DNA with ARC docs)
['link', { rel: 'preconnect', href: 'https://fonts.googleapis.com' }],
@@ -50,8 +50,8 @@ export default defineConfig({
{
text: 'Surfaces',
items: [
{ text: 'Android (phone control)', link: '/guide/' },
{ text: 'Desktop CLI (terminal)', link: '/desktop/' },
{ text: 'Android (companion)', link: '/guide/' },
{ text: 'CLI (remote hands)', link: '/desktop/' },
],
},
{ text: 'Features', link: '/features/' },
@@ -67,6 +67,7 @@ export default defineConfig({
text: 'Android (phone control)',
items: [
{ text: 'Overview', link: '/guide/' },
{ text: 'Quick Start', link: '/guide/quick-start' },
{ text: 'Installation & Setup', link: '/guide/getting-started' },
{ text: 'Remote access', link: '/guide/remote-access' },
{ text: 'Release tracks', link: '/guide/release-tracks' },
@@ -76,9 +77,9 @@ export default defineConfig({
],
},
{
text: 'Looking for the desktop CLI?',
text: 'Looking for the CLI?',
items: [
{ text: 'Desktop CLI overview', link: '/desktop/' },
{ text: 'CLI overview', link: '/desktop/' },
],
},
],
@@ -93,8 +94,11 @@ export default defineConfig({
{ text: 'Connections', link: '/features/connections' },
{ text: 'Profiles', link: '/features/profiles' },
{ text: 'Personalities', link: '/features/personalities' },
{ text: 'Voice Mode', link: '/features/voice' },
{ text: 'Voice Intents', link: '/features/voice-intents' },
{ text: 'Token Tracking', link: '/features/tokens' },
{ text: 'Tool Progress', link: '/features/tools' },
{ text: 'Phone Control Tools', link: '/features/phone-control-tools' },
{ text: 'Dashboard Plugin', link: '/features/dashboard' },
],
},
@@ -105,6 +109,7 @@ export default defineConfig({
items: [
{ text: 'Overview', link: '/architecture/' },
{ text: 'Relay Architecture Spec', link: '/architecture/relay-architecture-spec' },
{ text: 'Flavor Differences', link: '/architecture/flavor-differences' },
{ text: 'Decisions', link: '/architecture/decisions' },
{ text: 'Security', link: '/architecture/security' },
{ text: 'Privacy', link: '/architecture/privacy' },
@@ -117,6 +122,7 @@ export default defineConfig({
items: [
{ text: 'Hermes API', link: '/reference/api' },
{ text: 'Configuration', link: '/reference/configuration' },
{ text: 'Relay Server', link: '/reference/relay-server' },
],
},
],
@@ -124,7 +130,7 @@ export default defineConfig({
{
// Sidebar header carries the experimental marker so it's visible
// on every page in the section, not just the overview.
text: 'Desktop CLI (terminal) · Experimental',
text: 'CLI (remote hands) · Experimental',
items: [
{ text: 'Overview', link: '/desktop/' },
{ text: 'Installation', link: '/desktop/installation' },
@@ -42,15 +42,15 @@ const ariaLabel = computed(() => `${label.value} feature`)
box-shadow: 0 0 0 2px rgba(180, 83, 9, 0.18);
}
/* Dark theme — VitePress sets `.dark` on <html>. Use a slightly brighter
* amber so the badge stays legible against the dark surface. */
/* Dark theme — VitePress sets `.dark` on <html>. RelayRefresh.Amber so the
* badge matches the app's status palette against the navy surface. */
.dark .experimental-badge {
background: rgba(251, 191, 36, 0.10);
color: #fbbf24;
border-color: rgba(251, 191, 36, 0.30);
background: rgba(242, 177, 75, 0.10);
color: #F2B14B;
border-color: rgba(242, 177, 75, 0.30);
}
.dark .experimental-badge .dot {
background: #fbbf24;
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.18);
background: #F2B14B;
box-shadow: 0 0 0 2px rgba(242, 177, 75, 0.18);
}
</style>
@@ -383,8 +383,8 @@ export default {}
<style scoped>
/* ══════════════════════════════════════════════════════════════════════
FeatureMatrix — flat, border-separated, single accent.
Matches the Nothing-inspired theme used by HermesFlow / InstallSection /
HeroDemo: Space Grotesk + Space Mono, --vp-c-brand-1 purple accent, no
Matches the relay cockpit theme used by HermesFlow / InstallSection /
HeroDemo: Space Grotesk + Space Mono, --vp-c-brand-1 Relay accent, no
gradients, no shadows, no blur.
══════════════════════════════════════════════════════════════════════ */
@@ -571,7 +571,7 @@ export default {}
letter-spacing: 0.04em;
}
.fm-cell-track--sl {
background: rgba(124, 58, 237, 0.04);
background: rgba(110, 124, 255, 0.06);
}
/* Support states — color, opacity, weight */
@@ -32,8 +32,8 @@ interface DiagramDef {
}>
}
const edgeStyle = { stroke: '#7C3AED', strokeWidth: 1.5 }
const edgeDim = { stroke: '#333', strokeWidth: 1 }
const edgeStyle = { stroke: '#6E7CFF', strokeWidth: 1.5 }
const edgeDim = { stroke: '#33365A', strokeWidth: 1 }
const edgeAnimated = true
const diagrams: Record<string, DiagramDef> = {
@@ -119,7 +119,7 @@ const diagrams: Record<string, DiagramDef> = {
// Completion
{ id: 'e9', source: 'delta', target: 'complete', style: edgeStyle },
{ id: 'e10', source: 'tool-pending', target: 'tool-completed', style: edgeDim, label: 'success' },
{ id: 'e11', source: 'tool-started', target: 'tool-failed', style: { stroke: '#EF4444', strokeWidth: 1 }, label: 'error' },
{ id: 'e11', source: 'tool-started', target: 'tool-failed', style: { stroke: '#FF6B78', strokeWidth: 1 }, label: 'error' },
{ id: 'e12', source: 'complete', target: 'run', animated: edgeAnimated, style: edgeStyle },
{ id: 'e13', source: 'tool-completed', target: 'run', style: edgeDim },
],
@@ -202,27 +202,30 @@ function handleFitView() {
<style>
@import '@vue-flow/core/dist/style.css';
/* Diagrams stay dark in both modes — cockpit instrument panels, like
terminal screenshots. Palette mirrors RelayRefresh (navy, warm-white
hairlines, Relay periwinkle accent). */
.hermes-flow-wrapper {
position: relative;
border: 1px solid #222;
border: 1px solid rgba(247, 246, 240, 0.14);
border-radius: 8px;
background: #0A0A0A;
background: #0B0C12;
margin: 16px 0;
overflow: hidden;
}
/* Override Vue Flow background */
.hermes-flow-wrapper .vue-flow {
background: #0A0A0A !important;
background: #0B0C12 !important;
}
/* Edge labels */
.hermes-flow-wrapper .vue-flow__edge-textbg {
fill: #0A0A0A;
fill: #0B0C12;
}
.hermes-flow-wrapper .vue-flow__edge-text {
fill: #999;
fill: #A7A4B7;
font-family: 'Space Mono', monospace;
font-size: 10px;
}
@@ -258,17 +261,17 @@ function handleFitView() {
justify-content: center;
width: 28px;
height: 28px;
background: #1A1A1A;
border: 1px solid #333;
background: #191B31;
border: 1px solid rgba(247, 246, 240, 0.18);
border-radius: 4px;
color: #999;
color: #A7A4B7;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
}
.hermes-flow-btn:hover {
border-color: #7C3AED;
color: #E8E8E8;
border-color: #6E7CFF;
color: #F7F6F0;
}
/* Pan cursor */
@@ -281,7 +284,7 @@ function handleFitView() {
}
.hermes-flow-fallback {
color: #666;
color: #68647D;
font-family: 'Space Mono', monospace;
font-size: 12px;
padding: 24px;
@@ -22,25 +22,25 @@ const props = defineProps<{
<style>
.hermes-flow-node {
background: #0A0A0A;
border: 1px solid #2A2A2A;
background: #121426;
border: 1px solid rgba(247, 246, 240, 0.16);
border-radius: 8px;
padding: 8px 16px;
font-family: 'Space Mono', monospace;
font-size: 11px;
letter-spacing: 0.03em;
color: #E8E8E8;
color: #F7F6F0;
white-space: nowrap;
transition: border-color 0.2s;
}
.hermes-flow-node:hover {
border-color: #7C3AED;
border-color: #6E7CFF;
}
.hermes-flow-node--accent {
border-color: #7C3AED;
box-shadow: 0 0 12px rgba(124, 58, 237, 0.15);
border-color: #6E7CFF;
box-shadow: 0 0 12px rgba(110, 124, 255, 0.22);
}
.hermes-flow-node__label {
@@ -52,12 +52,12 @@ const props = defineProps<{
.hermes-flow-node .vue-flow__handle {
width: 6px;
height: 6px;
background: #444;
background: #33365A;
border: none;
transition: background 0.2s;
}
.hermes-flow-node:hover .vue-flow__handle {
background: #7C3AED;
background: #6E7CFF;
}
</style>
@@ -1,53 +1,419 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { withBase } from 'vitepress'
// Canonical sphere algorithm — same import path as SphereMark.vue. The hero
// demo is a code recreation of the app (not a video): DOM chat chrome over the
// real MorphingSphereCore algorithm, driven through the actual product state
// machine (Idle → Listening → Thinking + toolCallBurst → Speaking → Idle).
import {
SphereState,
paramsFor,
colorsFor,
forEachSphereCell,
} from '../../../../preview/web/sphere.js'
const TRANSITION_AT_SECONDS = 12
// ── Scene script ────────────────────────────────────────────────────────────
// One master clock, looped with modulo — every visual is a pure function of
// t, so the loop restart is just t wrapping to 0. Timings in seconds.
const PROMPT = 'Quick check — server uptime and memory?'
const ANSWER = 'Uptime: 46d 0h 42m\nMemory: 12.6 GiB / 32.4 GiB (39%)'
const TYPE_CPS = 17
const STREAM_CPS = 30
const showLogo = ref(false)
const videoEl = ref<HTMLVideoElement | null>(null)
const CHECKS = ['state restored', 'route · LAN', 'hermes online']
const CHECK_AT = [0.7, 1.5, 2.3]
const BOOT_END = 3.3 // boot text fades, chat chrome fades in
const TYPE_START = 4.3
const TYPE_END = TYPE_START + PROMPT.length / TYPE_CPS
const SEND_AT = TYPE_END + 0.45
const INDICATOR_AT = SEND_AT + 0.5
const TOOL_AT = SEND_AT + 1.5
const TOOL_DONE = TOOL_AT + 3.0
const ANSWER_START = TOOL_DONE + 0.5
const ANSWER_END = ANSWER_START + ANSWER.length / STREAM_CPS
const IDLE_AT = ANSWER_END + 0.8
const FADE_AT = ANSWER_END + 3.6
const LOOP_LEN = FADE_AT + 0.8
function handleTimeUpdate() {
if (showLogo.value) return
const v = videoEl.value
if (v && v.currentTime >= TRANSITION_AT_SECONDS) {
showLogo.value = true
// ── Reactive scene state (SSR renders the boot frame) ──────────────────────
const bootGone = ref(false)
const checksDone = ref(0)
const typed = ref('')
const sent = ref(false)
const indicatorOn = ref(false)
const toolState = ref<'none' | 'running' | 'done'>('none')
const answerText = ref('')
const answerDone = ref(false)
const fadingOut = ref(false)
const canvasEl = ref<HTMLCanvasElement | null>(null)
const screenEl = ref<HTMLDivElement | null>(null)
// ── Sphere plumbing (lean cut of SphereMark's tween rig — no gaze) ──────────
const COLS = 58
const ROWS = 34
class Tween {
current: number; target: number; start: number; startTime: number; duration: number
constructor(value: number) {
this.current = value; this.target = value; this.start = value
this.startTime = 0; this.duration = 0.8
}
setTarget(v: number, nowSec: number, duration = 0.8) {
if (v === this.target) return
this.start = this.current; this.target = v
this.startTime = nowSec; this.duration = duration
}
update(nowSec: number) {
if (this.duration <= 0) { this.current = this.target; return }
// Clamp at BOTH ends — smoothstep fed a negative time extrapolates
// cubically and explodes the params (the periods-in-the-eye bug).
const t = Math.min(1, Math.max(0, (nowSec - this.startTime) / this.duration))
const e = t * t * (3 - 2 * t)
this.current = this.start + (this.target - this.start) * e
}
}
// Failsafe in case timeupdate never fires (slow connection, paused autoplay)
let fallbackTimer: ReturnType<typeof setTimeout> | null = null
const initialP = paramsFor(SphereState.Thinking)
const initialC = colorsFor(SphereState.Thinking)
const tw = {
breatheSpeed: new Tween(initialP.breatheSpeed),
breatheAmp: new Tween(initialP.breatheAmp),
lightSpeedX: new Tween(initialP.lightSpeedX),
lightSpeedY: new Tween(initialP.lightSpeedY),
lightInfluence: new Tween(initialP.lightInfluence),
coreTightness: new Tween(initialP.coreTightness),
turbulenceAmp: new Tween(initialP.turbulenceAmp),
rippleScale: new Tween(initialP.rippleScale),
heartbeatSpeed: new Tween(initialP.heartbeatSpeed),
radialFlowSpeed: new Tween(initialP.radialFlowSpeed),
cr1: new Tween(initialC.r1), cg1: new Tween(initialC.g1), cb1: new Tween(initialC.b1),
cr2: new Tween(initialC.r2), cg2: new Tween(initialC.g2), cb2: new Tween(initialC.b2),
intensity: new Tween(0),
}
let sphereState: string = SphereState.Thinking
function retargetTo(state: string, nowSec: number) {
if (state === sphereState) return
sphereState = state
const p = paramsFor(state)
const c = colorsFor(state)
tw.breatheSpeed.setTarget(p.breatheSpeed, nowSec)
tw.breatheAmp.setTarget(p.breatheAmp, nowSec)
tw.lightSpeedX.setTarget(p.lightSpeedX, nowSec)
tw.lightSpeedY.setTarget(p.lightSpeedY, nowSec)
tw.lightInfluence.setTarget(p.lightInfluence, nowSec)
tw.coreTightness.setTarget(p.coreTightness, nowSec)
tw.turbulenceAmp.setTarget(p.turbulenceAmp, nowSec)
tw.rippleScale.setTarget(p.rippleScale, nowSec)
tw.heartbeatSpeed.setTarget(p.heartbeatSpeed, nowSec)
tw.radialFlowSpeed.setTarget(p.radialFlowSpeed, nowSec)
tw.cr1.setTarget(c.r1, nowSec); tw.cg1.setTarget(c.g1, nowSec); tw.cb1.setTarget(c.b1, nowSec)
tw.cr2.setTarget(c.r2, nowSec); tw.cg2.setTarget(c.g2, nowSec); tw.cb2.setTarget(c.b2, nowSec)
}
// ── Clock — accumulates only while on screen, so the loop resumes where it
// paused instead of jump-cutting when the reader scrolls back up. ───────────
let rafId = 0
let elapsed = 0
let lastFrameMs = 0
let isVisible = true
let reduceMotion = false
function sceneStateFor(t: number) {
if (t < BOOT_END) return SphereState.Thinking
if (t >= TYPE_START && t < SEND_AT) return SphereState.Listening
if (t >= SEND_AT && t < ANSWER_START) return SphereState.Thinking
if (t >= ANSWER_START && t < IDLE_AT) return SphereState.Speaking
return SphereState.Idle
}
function applyScene(t: number) {
bootGone.value = t >= BOOT_END
let n = 0
for (let i = 0; i < CHECK_AT.length; i++) if (t >= CHECK_AT[i]) n++
checksDone.value = n
const typedCount = Math.max(0, Math.min(PROMPT.length, Math.floor((t - TYPE_START) * TYPE_CPS)))
typed.value = t >= SEND_AT ? '' : PROMPT.slice(0, typedCount)
sent.value = t >= SEND_AT
indicatorOn.value = t >= INDICATOR_AT && t < TOOL_AT
toolState.value = t < TOOL_AT ? 'none' : t < TOOL_DONE ? 'running' : 'done'
const streamed = Math.max(0, Math.min(ANSWER.length, Math.floor((t - ANSWER_START) * STREAM_CPS)))
answerText.value = t < ANSWER_START ? '' : ANSWER.slice(0, streamed)
answerDone.value = t >= ANSWER_END
fadingOut.value = t >= FADE_AT
}
function resize() {
const canvas = canvasEl.value
if (!canvas) return
const dpr = window.devicePixelRatio || 1
const cw = canvas.clientWidth
const ch = canvas.clientHeight
if (cw <= 0 || ch <= 0) return
canvas.width = Math.floor(cw * dpr)
canvas.height = Math.floor(ch * dpr)
const ctx = canvas.getContext('2d')
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
// sceneT loops (drives WHAT the sphere is doing); clockT is monotonic and
// drives the tween rig + noise fields. The app's sphere clock never rewinds
// (MorphingSphere.kt animatedTime), so neither can ours — a looped clock sent
// the tweens a negative elapsed at every wrap and the extrapolation slammed
// char indices to the ramp floor: rings of '·'/'.' through the sphere's eye.
function drawSphere(sceneT: number, clockT: number) {
const canvas = canvasEl.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
if (!canvas.width || !canvas.height) {
resize()
if (!canvas.width || !canvas.height) return
}
retargetTo(sceneStateFor(sceneT), clockT)
for (const k in tw) (tw as Record<string, Tween>)[k].update(clockT)
// Streaming shimmer while Speaking; toolCallBurst pulse decays from the
// moment the tool card lands — same inputs the app feeds the renderer.
tw.intensity.setTarget(sphereState === SphereState.Speaking ? 0.4 : 0, clockT, 0.4)
const burst = sceneT >= TOOL_AT && sceneT < TOOL_AT + 1.2 ? Math.exp(-(sceneT - TOOL_AT) / 0.35) : 0
const canvasW = canvas.clientWidth
const canvasH = canvas.clientHeight
const cellW = canvasW / COLS
const cellH = canvasH / ROWS
const charSize = Math.min(cellW * 1.3, cellH * 1.1)
ctx.clearRect(0, 0, canvasW, canvasH)
ctx.font = `${charSize}px "Space Mono", ui-monospace, Menlo, Consolas, monospace`
ctx.textBaseline = 'alphabetic'
const time = reduceMotion ? 0 : clockT
const frame = {
cols: COLS,
rows: ROWS,
charAspect: cellW / cellH,
state: sphereState,
time,
colorPhase: (((time * 1000) % 8000) / 8000) * 6.2832,
breatheSpeed: tw.breatheSpeed.current,
breatheAmp: tw.breatheAmp.current,
lightSpeedX: tw.lightSpeedX.current,
lightSpeedY: tw.lightSpeedY.current,
lightInfluence: tw.lightInfluence.current,
coreTightness: tw.coreTightness.current,
turbulenceAmp: tw.turbulenceAmp.current,
rippleScale: tw.rippleScale.current,
heartbeatSpeed: tw.heartbeatSpeed.current,
radialFlowSpeed: tw.radialFlowSpeed.current,
cr1: tw.cr1.current, cg1: tw.cg1.current, cb1: tw.cb1.current,
cr2: tw.cr2.current, cg2: tw.cg2.current, cb2: tw.cb2.current,
intensity: tw.intensity.current,
toolCallBurst: burst,
voiceAmplitude: 0,
voiceMode: false,
voiceRadiusScale: 1,
lightAngleBiasX: 0,
lightAngleBiasY: 0,
// Natural light orbit only — gaze tracking is SphereMark's job further
// down the page; two competing eyes on one page would fight for attention.
lightAngleBlend: 0,
// The app passes no shadowStrength (MorphingSphere.kt → core default 0):
// legacy pearl shading. SphereMark's 0.6 is its own eye look, not the app's.
shadowStrength: 0,
}
forEachSphereCell(frame, (col: number, row: number, ch: string, r: number, g: number, b: number, a: number) => {
const px = col * cellW
const py = row * cellH + cellH * 0.8
ctx.fillStyle = `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a.toFixed(3)})`
ctx.fillText(ch, px, py)
})
}
function render() {
const nowMs = performance.now()
const dt = Math.min(0.1, (nowMs - lastFrameMs) / 1000)
lastFrameMs = nowMs
if (isVisible) {
elapsed += dt
applyScene(elapsed % LOOP_LEN)
drawSphere(elapsed % LOOP_LEN, elapsed)
}
rafId = requestAnimationFrame(render)
}
// Static frames (scrubber / reduced motion): retarget once, then snap every
// tween to its target so the frozen frame shows settled params, not the
// mid-transition values a single update() at t=startTime would give.
function drawSphereSettled(sceneT: number, clockT: number) {
drawSphere(sceneT, clockT)
for (const k in tw) {
const w = (tw as Record<string, Tween>)[k]
w.current = w.target
w.start = w.target
}
drawSphere(sceneT, clockT)
}
let intersectionObserver: IntersectionObserver | null = null
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
fallbackTimer = setTimeout(() => {
showLogo.value = true
}, (TRANSITION_AT_SECONDS + 3) * 1000)
reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
resize()
// Design-review scrubber: ?demoT=<seconds> freezes the scene at that point
// of the timeline (also what headless screenshot tooling uses — rAF-driven
// clocks don't advance reliably under virtual-time fast-forward).
const forcedT = new URLSearchParams(window.location.search).get('demoT')
if (forcedT !== null && !Number.isNaN(parseFloat(forcedT))) {
const t = parseFloat(forcedT) % LOOP_LEN
screenEl.value?.classList.add('had-static')
applyScene(t)
drawSphereSettled(t, t)
return
}
if (reduceMotion) {
// Static completed scene: full conversation visible, sphere drawn once.
applyScene(IDLE_AT + 0.1)
fadingOut.value = false
drawSphereSettled(IDLE_AT + 0.1, 0)
return
}
lastFrameMs = performance.now()
if (screenEl.value) {
intersectionObserver = new IntersectionObserver(
(entries) => { for (const entry of entries) isVisible = entry.isIntersecting },
{ threshold: 0 }
)
intersectionObserver.observe(screenEl.value)
resizeObserver = new ResizeObserver(() => resize())
resizeObserver.observe(screenEl.value)
}
rafId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
if (fallbackTimer) clearTimeout(fallbackTimer)
cancelAnimationFrame(rafId)
intersectionObserver?.disconnect()
resizeObserver?.disconnect()
})
</script>
<template>
<div class="hero-demo">
<div class="hero-demo-frame">
<Transition name="hero-fade" mode="out-in">
<video
v-if="!showLogo"
ref="videoEl"
key="video"
class="hero-demo-media hero-demo-video"
:src="withBase('/chat_demo.mp4')"
:poster="withBase('/chat_demo_poster.jpg')"
autoplay
muted
playsinline
preload="metadata"
@timeupdate="handleTimeUpdate"
/>
<div v-else key="logo" class="hero-demo-media hero-demo-logo">
<img :src="withBase('/logo.svg')" alt="Hermes-Relay" />
<div class="hero-demo" role="img"
aria-label="Animated demo of the Hermes-Relay Android app: it connects to your Hermes agent, runs a server health check through a tool call, and streams the answer back.">
<div class="hero-demo-frame" aria-hidden="true">
<div ref="screenEl" class="had-screen" :class="{ 'had-fading': fadingOut }">
<!-- Sphere — one canvas shared by boot and chat so the gate sphere
visibly settles into being the conversation's backdrop. -->
<canvas ref="canvasEl" class="had-sphere" :class="{ 'had-sphere-chat': bootGone }"></canvas>
<!-- Boot gate -->
<div class="had-boot" :class="{ 'had-hidden': bootGone }">
<div class="had-boot-title">Hermes-Relay</div>
<div class="had-boot-sub">agent interface</div>
<div class="had-boot-checks">
<div v-for="(label, i) in CHECKS" :key="label" class="had-check"
:class="{ 'had-check-on': checksDone > i }">
<span class="had-check-mark">{{ checksDone > i ? '✓' : '·' }}</span> {{ label }}
</div>
</div>
</div>
</Transition>
<!-- Chat -->
<div class="had-chat" :class="{ 'had-hidden': !bootGone }">
<!-- Header mirrors the live app 1:1 (assets/screenshots/02_chat.png):
drawer hamburger · avatar+presence · name/model · LAN chip ·
share / inspector / tune action cluster. -->
<div class="had-header">
<svg class="had-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round">
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
<div class="had-avatar">H<span class="had-avatar-dot"></span></div>
<div class="had-id">
<div class="had-name">Hermes</div>
<div class="had-model">gpt-5.5 · default</div>
</div>
<div class="had-chip">LAN</div>
<!-- Three SEPARATE bordered buttons in the app — not one cluster. -->
<span class="had-btn">
<svg viewBox="0 0 24 24" fill="currentColor">
<circle cx="18" cy="5" r="2.7" /><circle cx="6" cy="12" r="2.7" /><circle cx="18" cy="19" r="2.7" />
<path d="M8.3 10.9 15.7 6.1M8.3 13.1l7.4 4.8" fill="none" stroke="currentColor"
stroke-width="2" />
</svg>
</span>
<span class="had-btn had-btn-code">&lt;/&gt;</span>
<span class="had-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round">
<path d="M4 7h9M19.5 7h.5M4 12h.5M11 12h9M4 17h9M19.5 17h.5" />
<circle cx="16" cy="7" r="2.3" /><circle cx="7.5" cy="12" r="2.3" /><circle cx="16" cy="17" r="2.3" />
</svg>
</span>
</div>
<div class="had-tabs">
<span class="had-tab had-tab-active">Chat</span>
<span class="had-tab">Manage</span>
<span class="had-tab">Bridge</span>
</div>
<div class="had-messages">
<div class="had-day">Today</div>
<div v-if="sent" class="had-bubble had-user">
{{ PROMPT }}
<div class="had-time">9:41 AM</div>
</div>
<div v-if="indicatorOn" class="had-agent-label">Hermes</div>
<div v-if="indicatorOn" class="had-bubble had-agent had-indicator">
<span></span><span></span><span></span>
</div>
<div v-if="toolState !== 'none'" class="had-tool">
<span class="had-tool-icon">&lt;&gt;</span>
<span class="had-tool-name">execute_code</span>
<template v-if="toolState === 'done'">
<span class="had-tool-done">✓</span>
<span class="had-tool-chev">⌄</span>
</template>
<span v-else class="had-tool-bar"><span></span></span>
</div>
<div v-if="answerText" class="had-agent-label">Hermes</div>
<div v-if="answerText" class="had-bubble had-agent had-answer">{{ answerText }}<span
v-if="!answerDone" class="had-caret"></span>
<div v-if="answerDone" class="had-time">9:41 AM</div>
</div>
</div>
<div class="had-input">
<span class="had-input-plus">+</span>
<span class="had-input-slash">/</span>
<div class="had-field" :class="{ 'had-field-busy': sent && !answerDone }">
<template v-if="typed">{{ typed }}<span class="had-caret"></span></template>
<span v-else class="had-placeholder">{{ sent && !answerDone ? 'Queue a message...' : 'Message...' }}</span>
</div>
<!-- Mic at rest, send arrow once there's text — same swap the app does. -->
<span v-if="typed" class="had-send had-send-ready">➤</span>
<svg v-else class="had-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round">
<rect x="9.1" y="2.6" width="5.8" height="11.2" rx="2.9" />
<path d="M5.4 11.6a6.6 6.6 0 0 0 13.2 0M12 18.2v3" />
</svg>
</div>
<div class="had-status">
<span class="had-status-ok">api online / LAN</span>
<span>gpt-5.5 / profile: default</span>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -60,18 +426,18 @@ onBeforeUnmount(() => {
width: 100%;
height: 100%;
}
/* Phone bezel — carried over from the video hero so the silhouette is stable. */
.hero-demo-frame {
position: relative;
display: block;
padding: 10px;
border-radius: 36px;
background: linear-gradient(160deg, #1a1a1a 0%, #0a0a0a 100%);
background: linear-gradient(160deg, #191B31 0%, #0B0C12 100%);
box-shadow:
0 0 0 1px var(--vp-c-divider),
0 30px 60px -20px rgba(0, 0, 0, 0.5),
0 0 80px -10px var(--vp-c-brand-soft);
width: clamp(180px, 62vw, 280px);
max-height: 70vh;
width: clamp(200px, 62vw, 290px);
margin: 0 auto;
box-sizing: border-box;
overflow: hidden;
@@ -86,52 +452,369 @@ onBeforeUnmount(() => {
height: 6px;
background: #000;
border-radius: 3px;
z-index: 2;
z-index: 3;
opacity: 0.6;
}
.hero-demo-media {
display: block;
width: 100%;
/* The screen is always the app's dark cockpit — that's what the app looks
like. Container-query units keep every element proportional to frame width. */
.had-screen {
position: relative;
container-type: inline-size;
aspect-ratio: 1080 / 2244;
border-radius: 28px;
background: #000;
background: #08090D;
overflow: hidden;
transition: opacity 600ms ease;
}
.hero-demo-video {
height: auto;
.had-fading { opacity: 0; }
.had-hidden { opacity: 0; pointer-events: none; }
/* ── Sphere canvas — settles from gate centerpiece into chat backdrop ── */
.had-sphere {
position: absolute;
left: 50%;
top: 26%;
width: 88cqw;
aspect-ratio: 1 / 1;
transform: translateX(-50%);
opacity: 0.95;
transition: top 900ms ease, opacity 900ms ease, width 900ms ease;
z-index: 0;
}
.hero-demo-logo {
aspect-ratio: 1080 / 2340;
.had-sphere-chat {
top: 34%;
width: 80cqw;
opacity: 0.6;
}
/* Scrubber mode (?demoT=) — freeze every transition so review frames are
exact, not caught mid-fade. */
.had-static, .had-static * { transition: none !important; animation: none !important; }
/* ── Boot gate ── */
.had-boot {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 12cqw;
transition: opacity 500ms ease;
}
.had-boot-title {
color: #F7F6F0;
font-size: 7.4cqw;
font-weight: 600;
letter-spacing: 0.02em;
}
.had-boot-sub {
color: rgba(247, 246, 240, 0.5);
font-family: var(--vp-font-family-mono);
font-size: 3.4cqw;
letter-spacing: 0.3em;
margin: 1.5cqw 0 6cqw;
}
.had-boot-checks {
font-family: var(--vp-font-family-mono);
font-size: 3.2cqw;
line-height: 1.9;
color: rgba(247, 246, 240, 0.35);
}
.had-check { transition: color 300ms ease; }
.had-check-on { color: rgba(247, 246, 240, 0.85); }
.had-check-mark { color: var(--hr-green); display: inline-block; width: 4cqw; }
/* ── Chat chrome ── */
.had-chat {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
transition: opacity 600ms ease;
}
.had-header {
display: flex;
align-items: center;
gap: 1.6cqw;
padding: 6.5cqw 3.5cqw 2cqw;
}
.had-menu {
width: 4.8cqw;
height: 4.8cqw;
flex: none;
color: #F7F6F0;
margin-right: 1cqw;
}
.had-btn {
width: 6.6cqw;
height: 6.6cqw;
flex: none;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(
ellipse at center,
rgba(155, 107, 240, 0.18) 0%,
rgba(0, 0, 0, 1) 70%
);
background: #0B0C14;
border: 1px solid rgba(247, 246, 240, 0.45);
border-radius: 2.2cqw;
color: #F7F6F0;
}
.hero-demo-logo img {
width: 55%;
height: auto;
filter: drop-shadow(0 0 24px rgba(155, 107, 240, 0.55));
.had-btn svg { width: 3.7cqw; height: 3.7cqw; }
.had-btn-code {
font-family: var(--vp-font-family-mono);
font-size: 2.5cqw;
font-weight: 700;
line-height: 1;
}
/* Light avatar with dark initial — matches the app, not inverted. */
.had-avatar {
position: relative;
width: 9.6cqw;
height: 9.6cqw;
border-radius: 50%;
background: #E9E7F2;
color: #16172B;
font-size: 4.2cqw;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex: none;
}
.had-avatar-dot {
position: absolute;
right: -0.4cqw;
bottom: -0.4cqw;
width: 2.8cqw;
height: 2.8cqw;
border-radius: 50%;
background: var(--hr-green);
border: 0.6cqw solid #08090D;
}
.had-id { flex: 1; min-width: 0; margin-left: 0.8cqw; }
.had-name {
color: #FFFFFF;
font-size: 3.7cqw;
font-weight: 700;
line-height: 1.25;
}
.had-model {
color: rgba(247, 246, 240, 0.55);
font-size: 2.7cqw;
line-height: 1.3;
}
/* Solid filled pill in the app — no outline. */
.had-chip {
flex: none;
font-size: 2.2cqw;
font-weight: 600;
color: #F7F6F0;
background: #34343E;
border-radius: 2.4cqw;
padding: 0.9cqw 2cqw;
margin-right: 0.6cqw;
}
.had-tabs {
display: flex;
gap: 2.4cqw;
padding: 2cqw 3.5cqw 2.5cqw;
}
.had-tab {
flex: 1;
text-align: center;
font-size: 3cqw;
font-weight: 600;
color: rgba(247, 246, 240, 0.85);
background: #0A0A10;
border: 1px solid rgba(247, 246, 240, 0.25);
border-radius: 3.6cqw;
padding: 2.1cqw 0;
}
.had-tab-active {
background: #20225A;
border-color: rgba(99, 110, 230, 0.65);
color: #FFFFFF;
}
/* Crossfade between video and logo */
.hero-fade-enter-active,
.hero-fade-leave-active {
transition: opacity 900ms ease;
.had-messages {
position: relative;
flex: 1;
padding: 1cqw 4.5cqw 0;
overflow: hidden;
}
.hero-fade-enter-from,
.hero-fade-leave-to {
opacity: 0;
.had-day {
width: fit-content;
margin: 0 auto 3cqw;
font-size: 2.8cqw;
color: rgba(247, 246, 240, 0.55);
background: #191B31;
border-radius: 3cqw;
padding: 0.9cqw 3cqw;
}
.had-bubble {
max-width: 78%;
border-radius: 3.6cqw;
padding: 2.6cqw 3.4cqw;
font-size: 3.4cqw;
line-height: 1.45;
margin-bottom: 2.6cqw;
white-space: pre-line;
animation: had-pop 350ms ease;
}
@keyframes had-pop {
from { opacity: 0; transform: translateY(2cqw); }
to { opacity: 1; transform: translateY(0); }
}
.had-user {
margin-left: auto;
background: #AEBFFF;
color: #14152A;
border-bottom-right-radius: 1.2cqw;
}
.had-agent {
margin-right: auto;
background: rgba(25, 27, 49, 0.92);
color: #F7F6F0;
border-bottom-left-radius: 1.2cqw;
}
/* The app renders the agent name as a small dark chip above the turn. */
.had-agent-label {
width: fit-content;
font-size: 2.7cqw;
color: rgba(247, 246, 240, 0.7);
background: #191B31;
border-radius: 2.2cqw;
padding: 0.8cqw 2.4cqw;
margin: 0 0 1.4cqw 0;
}
.had-time {
font-size: 2.5cqw;
opacity: 0.55;
margin-top: 1.2cqw;
}
.had-indicator { display: inline-flex; gap: 1.4cqw; padding: 3cqw 3.6cqw; }
.had-indicator span {
width: 1.8cqw;
height: 1.8cqw;
border-radius: 50%;
background: rgba(247, 246, 240, 0.7);
animation: had-bounce 1.2s infinite ease-in-out;
}
.had-indicator span:nth-child(2) { animation-delay: 0.15s; }
.had-indicator span:nth-child(3) { animation-delay: 0.3s; }
@keyframes had-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
30% { transform: translateY(-1.2cqw); opacity: 1; }
}
.had-tool {
display: flex;
align-items: center;
gap: 2.2cqw;
background: rgba(18, 20, 38, 0.95);
border: 1px solid rgba(247, 246, 240, 0.1);
border-radius: 2.6cqw;
padding: 2.4cqw 3.2cqw;
margin-bottom: 2.6cqw;
font-family: var(--vp-font-family-mono);
font-size: 3cqw;
color: rgba(247, 246, 240, 0.85);
animation: had-pop 350ms ease;
}
.had-tool-icon { color: var(--vp-c-brand-1); font-size: 2.8cqw; }
.had-tool-name { flex: 1; }
.had-tool-done { color: rgba(247, 246, 240, 0.75); font-size: 3cqw; }
.had-tool-chev { color: rgba(247, 246, 240, 0.45); font-size: 3cqw; line-height: 0.6; }
.had-tool-bar {
flex: none;
width: 16cqw;
height: 1.1cqw;
border-radius: 1cqw;
background: rgba(247, 246, 240, 0.12);
overflow: hidden;
}
.had-tool-bar span {
display: block;
width: 40%;
height: 100%;
border-radius: 1cqw;
background: #6E7CFF;
animation: had-scan 1.1s infinite ease-in-out;
}
@keyframes had-scan {
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}
.had-caret {
display: inline-block;
width: 0.5cqw;
height: 3.4cqw;
background: #AEBFFF;
margin-left: 0.6cqw;
vertical-align: -0.5cqw;
animation: had-blink 0.9s steps(1) infinite;
}
@keyframes had-blink { 50% { opacity: 0; } }
.had-input {
display: flex;
align-items: center;
gap: 2.4cqw;
padding: 2cqw 4.5cqw 1.6cqw;
}
.had-input-plus, .had-input-slash {
color: rgba(247, 246, 240, 0.6);
font-size: 4.4cqw;
}
.had-input-slash { font-family: var(--vp-font-family-mono); font-size: 3.6cqw; }
.had-field {
flex: 1;
min-height: 8.4cqw;
display: flex;
align-items: center;
border: 1px solid #4F5BD5;
border-radius: 2.8cqw;
padding: 1.2cqw 3cqw;
font-size: 3.3cqw;
color: #F7F6F0;
transition: border-color 300ms ease;
}
.had-field-busy { border-color: rgba(247, 246, 240, 0.2); }
.had-placeholder { color: rgba(247, 246, 240, 0.35); }
.had-send {
color: rgba(247, 246, 240, 0.35);
font-size: 4.2cqw;
transition: color 300ms ease;
}
.had-send-ready { color: #6E7CFF; }
.had-mic {
width: 5.2cqw;
height: 5.2cqw;
flex: none;
color: #AEBFFF;
}
.had-status {
display: flex;
justify-content: space-between;
font-family: var(--vp-font-family-mono);
font-size: 2.5cqw;
color: rgba(247, 246, 240, 0.45);
background: #0B0C14;
padding: 1.6cqw 4.5cqw 2.2cqw;
}
.had-status-ok { color: var(--hr-green); }
@media (max-width: 640px) {
.hero-demo-frame {
padding: 8px;
border-radius: 30px;
}
.hero-demo-media {
border-radius: 22px;
}
.had-screen { border-radius: 22px; }
}
</style>
@@ -0,0 +1,94 @@
<script setup lang="ts">
// Three-step "how it works" strip — sits between the sphere mark and the
// install section on the home page (slotted via theme/index.ts, since
// markdown body content always renders after the VPFeatures grid).
const steps = [
{
num: '01 · Run',
title: 'Hermes on your machine',
body: 'Any box that runs Hermes — server, desktop, laptop. Its API serves chat; its dashboard serves Manage and voice. Nothing extra to install.',
},
{
num: '02 · Install',
title: 'The app on your phone',
body: 'Google Play or sideload — two tracks, one app. The sideload track adds full Device Control later if you want it.',
},
{
num: '03 · Connect',
title: 'Point it at your Hermes',
body: 'The wizard scans your LAN, accepts a setup QR, or takes a plain URL. Chat, voice, and Manage light up.',
},
]
</script>
<template>
<section class="how-strip" aria-label="How Hermes-Relay works">
<div class="how-strip-label">How it works</div>
<div class="how-steps">
<div v-for="step in steps" :key="step.num" class="how-step">
<span class="num">{{ step.num }}</span>
<h4>{{ step.title }}</h4>
<p>{{ step.body }}</p>
</div>
</div>
<p class="how-power-note">
Want phone control, terminal, or hands on your computers? Add the relay plugin — the power path below.
</p>
</section>
</template>
<style scoped>
.how-strip {
max-width: 960px;
margin: 3rem auto 0;
padding: 0 24px;
}
.how-strip-label {
text-align: center;
font-family: var(--vp-font-family-mono);
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--vp-c-text-3);
margin-bottom: 1.25rem;
}
.how-steps {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.how-steps { grid-template-columns: 1fr 1fr 1fr; }
}
.how-step {
background: var(--vp-c-bg-alt);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1.1rem 1.25rem;
}
.how-step .num {
font-family: var(--vp-font-family-mono);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--vp-c-brand-1);
}
.how-step h4 {
margin: 0.35rem 0 0.3rem;
font-size: 1rem;
font-weight: 600;
}
.how-step p {
margin: 0;
font-size: 0.85rem;
line-height: 1.55;
color: var(--vp-c-text-2);
}
.how-power-note {
text-align: center;
font-size: 0.85rem;
color: var(--vp-c-text-3);
margin: 1.25rem auto 0;
max-width: 640px;
}
</style>
@@ -4,13 +4,12 @@ import { withBase } from 'vitepress'
const installCommand = 'curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash'
const pairCommand = 'hermes-pair'
const desktopInstallShell = 'curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh'
const desktopInstallPwsh = 'irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex'
// Copy-paste prompt block for AI agents (Claude, GPT, etc.) — points the
// agent at the canonical SKILL.md raw URL, no inline recipe duplication.
// Single source of truth lives at skills/devops/hermes-relay-self-setup/SKILL.md.
const agentPrompt = `You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a desktop CLI + a Python plugin for the Hermes AI agent platform.
const agentPrompt = `You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a CLI + a Python plugin for the Hermes AI agent platform.
Read the canonical setup recipe before acting:
https://raw.githubusercontent.com/Codename-11/hermes-relay/main/skills/devops/hermes-relay-self-setup/SKILL.md
@@ -18,7 +17,7 @@ Read the canonical setup recipe before acting:
Then guide me through:
- Verifying hermes-agent is already installed (it's a prerequisite — Hermes-Relay is a plugin, not standalone)
- Running the install one-liner: \`curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash\`
- Pairing my phone via \`hermes-pair\` or \`/hermes-relay-pair\` (Android), or my laptop via the \`hermes-relay\` desktop CLI (binary one-liner: \`curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh\` or \`irm .../install.ps1 | iex\` on Windows)
- Pairing my phone via \`hermes-pair\` or \`/hermes-relay-pair\` (Android), or another machine via the \`hermes-relay\` CLI (Windows binary one-liner: \`irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex\`; macOS/Linux builds coming soon)
- Verifying with \`hermes-status\`
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.`
@@ -42,117 +41,125 @@ async function copy(key: string, text: string) {
<template>
<section class="install-section">
<h2>Install in 30 seconds</h2>
<h2>Get started</h2>
<p class="install-tagline">
Step 1 — install the server plugin into your Hermes agent. After restarting hermes, run <code>hermes-pair</code> (or type <code>/hermes-relay-pair</code> in any Hermes chat surface) to generate a QR / 6-char code that either the <a :href="withBase('/guide/getting-started')">Android app</a> or the <a :href="withBase('/desktop/')">desktop CLI</a> can use to pair.
Two paths. Most people start with the first — the second adds the agent's hands when you want them.
</p>
<div class="install-code">
<div class="install-code-scroll">
<pre><code>{{ installCommand }}</code></pre>
</div>
<button
type="button"
class="copy-btn"
:class="{ copied: copiedKey === 'install' }"
:aria-label="copiedKey === 'install' ? 'Copied' : 'Copy install command'"
@click="copy('install', installCommand)"
>
<span class="copy-btn-label">{{ copiedKey === 'install' ? 'Copied!' : 'Copy' }}</span>
</button>
<!-- ── Quick path — no server install ─────────────────────────────── -->
<div class="path-card">
<div class="path-tag">Quick path · No server install</div>
<h3>Just connect</h3>
<p>
Already running <a href="https://hermes-agent.nousresearch.com" target="_blank" rel="noopener">Hermes</a>? Then the server side is done. Install the Android app, open it, and point it at your Hermes — the wizard scans your LAN, accepts a setup QR, or takes a plain URL. Chat, voice, and Manage light up.
</p>
<p class="path-note">
Chat talks to the Hermes API server; Manage and voice use the dashboard — most setups run both. Pure chat technically needs only the API. No relay plugin required.
</p>
<p class="install-extra-actions">
<a :href="withBase('/guide/getting-started')" class="install-cta-link">Android setup →</a>
<a
href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"
class="install-extra-secondary"
target="_blank"
rel="noopener"
>Google Play</a>
<a
href="https://github.com/Codename-11/hermes-relay/releases/latest"
class="install-extra-secondary"
target="_blank"
rel="noopener"
>Sideload APK</a>
</p>
</div>
<p class="install-then">Then mint a pairing code:</p>
<!-- ── Power path — relay plugin ──────────────────────────────────── -->
<div class="path-card">
<div class="path-tag">Power path · Relay plugin</div>
<h3>Give it hands</h3>
<p>
One install on your Hermes host unlocks the rest: phone control on the sideload track, terminal and relay sessions on Android, and the CLI that puts hands on your computers.
</p>
<div class="install-code">
<div class="install-code-scroll">
<pre><code>{{ pairCommand }}</code></pre>
<div class="install-code">
<div class="install-code-scroll">
<pre><code>{{ installCommand }}</code></pre>
</div>
<button
type="button"
class="copy-btn"
:class="{ copied: copiedKey === 'install' }"
:aria-label="copiedKey === 'install' ? 'Copied' : 'Copy install command'"
@click="copy('install', installCommand)"
>
<span class="copy-btn-label">{{ copiedKey === 'install' ? 'Copied!' : 'Copy' }}</span>
</button>
</div>
<button
type="button"
class="copy-btn"
:class="{ copied: copiedKey === 'pair' }"
:aria-label="copiedKey === 'pair' ? 'Copied' : 'Copy pair command'"
@click="copy('pair', pairCommand)"
>
<span class="copy-btn-label">{{ copiedKey === 'pair' ? 'Copied!' : 'Copy' }}</span>
</button>
<p class="install-then">Then mint a pairing QR / 6-char code and scan it from the app:</p>
<div class="install-code">
<div class="install-code-scroll">
<pre><code>{{ pairCommand }}</code></pre>
</div>
<button
type="button"
class="copy-btn"
:class="{ copied: copiedKey === 'pair' }"
:aria-label="copiedKey === 'pair' ? 'Copied' : 'Copy pair command'"
@click="copy('pair', pairCommand)"
>
<span class="copy-btn-label">{{ copiedKey === 'pair' ? 'Copied!' : 'Copy' }}</span>
</button>
</div>
<p class="path-note">
Installs the 18 <code>android_*</code> + 9 <code>desktop_*</code> tool surfaces, the <code>/hermes-relay-pair</code> skill, and a <code>hermes-pair</code> shell shim. Requires hermes-agent v0.8.0+ and Python 3.11+.
</p>
<p>
For hands on a computer, add the CLI:
</p>
<div class="install-code">
<div class="install-code-scroll">
<pre><code>{{ desktopInstallPwsh }}</code></pre>
</div>
<button
type="button"
class="copy-btn"
:class="{ copied: copiedKey === 'desktop-pwsh' }"
:aria-label="copiedKey === 'desktop-pwsh' ? 'Copied' : 'Copy CLI install (pwsh)'"
@click="copy('desktop-pwsh', desktopInstallPwsh)"
>
<span class="copy-btn-label">{{ copiedKey === 'desktop-pwsh' ? 'Copied!' : 'Copy' }}</span>
</button>
</div>
<p class="path-note">
Windows today — the macOS / Linux one-liner lands with those builds. Track it on <a :href="withBase('/desktop/installation')">CLI setup</a>.
</p>
<p class="install-extra-actions">
<a :href="withBase('/desktop/')" class="install-cta-link">CLI setup →</a>
<a :href="withBase('/features/phone-control-tools')" class="install-extra-secondary">Phone control tools</a>
</p>
</div>
<p class="install-note">
Installs the 18 <code>android_*</code> + 9 <code>desktop_*</code> tool surfaces, the <code>/hermes-relay-pair</code> skill, and a <code>hermes-pair</code> shell shim. Requires hermes-agent v0.8.0+ and Python 3.11+.
</p>
<p class="install-cta">
<a :href="withBase('/guide/getting-started')" class="install-cta-link">Android setup →</a>
<a :href="withBase('/desktop/')" class="install-cta-link">Desktop CLI setup →</a>
</p>
<div class="install-extras">
<div class="install-extra-card">
<h3>Step 2 — your client</h3>
<p>
Pair the <strong>Android app</strong> (sideload the file ending in <code>-sideload-release.apk</code> from the latest GitHub Release for the full-featured build, or wait for Google Play), <strong>or</strong> install the <strong>desktop CLI</strong> binary with one of the one-liners below — same pair, either client. Bun-compiled binary, no Node required.
</p>
<div class="install-code">
<div class="install-code-scroll">
<pre><code>{{ desktopInstallShell }}</code></pre>
</div>
<button
type="button"
class="copy-btn"
:class="{ copied: copiedKey === 'desktop-shell' }"
:aria-label="copiedKey === 'desktop-shell' ? 'Copied' : 'Copy desktop install (sh)'"
@click="copy('desktop-shell', desktopInstallShell)"
>
<span class="copy-btn-label">{{ copiedKey === 'desktop-shell' ? 'Copied!' : 'Copy' }}</span>
</button>
</div>
<div class="install-code">
<div class="install-code-scroll">
<pre><code>{{ desktopInstallPwsh }}</code></pre>
</div>
<button
type="button"
class="copy-btn"
:class="{ copied: copiedKey === 'desktop-pwsh' }"
:aria-label="copiedKey === 'desktop-pwsh' ? 'Copied' : 'Copy desktop install (pwsh)'"
@click="copy('desktop-pwsh', desktopInstallPwsh)"
>
<span class="copy-btn-label">{{ copiedKey === 'desktop-pwsh' ? 'Copied!' : 'Copy' }}</span>
</button>
</div>
<p class="install-extra-actions">
<a
href="https://github.com/Codename-11/hermes-relay/releases/latest"
class="install-cta-link"
target="_blank"
rel="noopener"
>Android APK →</a>
<a
:href="withBase('/desktop/')"
class="install-extra-secondary"
>Desktop CLI guide</a>
</p>
</div>
<div class="install-extra-card">
<h3>Found a bug? We'd love to hear about it.</h3>
<p>
This is an indie project and every report helps. If something feels off, broken, or just weird, open an issue — we read every one.
</p>
<p class="install-extra-actions">
<a
href="https://github.com/Codename-11/hermes-relay/issues/new"
class="install-cta-link"
target="_blank"
rel="noopener"
>Open an issue →</a>
</p>
</div>
<!-- ── Bug-report card ────────────────────────────────────────────── -->
<div class="path-card path-card--quiet">
<h3>Found a bug? We'd love to hear about it.</h3>
<p>
This is an indie project and every report helps. If something feels off, broken, or just weird, open an issue — we read every one.
</p>
<p class="install-extra-actions">
<a
href="https://github.com/Codename-11/hermes-relay/issues/new"
class="install-cta-link"
target="_blank"
rel="noopener"
>Open an issue →</a>
</p>
</div>
<!-- For AI Agents — copy-paste block that points an LLM at the canonical
@@ -160,7 +167,7 @@ async function copy(key: string, text: string) {
<div class="agent-section">
<h3 class="agent-section-title">For AI Agents</h3>
<p class="agent-section-tagline">
Have an AI assistant install + maintain Hermes-Relay for you. Paste this block into Claude, GPT, or any agent — it'll fetch the canonical setup recipe and walk you through verification, pairing, and troubleshooting.
On the power path? Have an AI assistant install + maintain the relay plugin for you. Paste this block into Claude, GPT, or any agent — it'll fetch the canonical setup recipe and walk you through verification, pairing, and troubleshooting.
</p>
<div class="install-code agent-prompt-code">
@@ -199,32 +206,88 @@ async function copy(key: string, text: string) {
}
.install-tagline,
.install-then {
text-align: center;
color: var(--vp-c-text-2);
max-width: 640px;
margin: 1rem auto;
margin: 1rem 0;
}
.install-code {
position: relative;
.install-tagline {
text-align: center;
max-width: 640px;
margin: 1rem auto 1.75rem;
}
/* ── Path cards — stacked, Standard first ─────────────────────────── */
.path-card {
max-width: 720px;
min-width: 0;
margin: 1rem auto;
background: var(--vp-c-bg-alt);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1.5rem 1.5rem 1.25rem;
}
.path-card h3 {
margin: 0.35rem 0 0.5rem;
font-size: 1.35rem;
border-top: none;
padding-top: 0;
letter-spacing: -0.01em;
}
.path-card p {
color: var(--vp-c-text-2);
font-size: 0.95rem;
line-height: 1.6;
margin: 0.5rem 0;
}
.path-card code {
font-size: 0.8125rem;
}
.path-tag {
font-family: var(--vp-font-family-mono);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--vp-c-brand-1);
}
.path-note {
font-size: 0.8125rem !important;
color: var(--vp-c-text-3) !important;
}
.path-platform {
font-family: var(--vp-font-family-mono);
font-size: 0.72rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--vp-c-text-3);
}
.path-card--quiet h3 {
font-size: 1.05rem;
margin-top: 0;
}
/* ── Code blocks ──────────────────────────────────────────────────── */
.install-code {
position: relative;
min-width: 0;
margin: 1rem 0;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
}
/* Scrolling lives on the inner container so the absolutely-positioned copy
button stays pinned to the visible top-right corner of .install-code,
instead of scrolling out of view with the overflowing command text. */
instead of scrolling out of view with the overflowing command text.
(overflow-x is a fallback — commands wrap below, so it rarely engages.) */
.install-code-scroll {
min-width: 0;
overflow-x: auto;
padding: 14px 58px 14px 18px;
}
/* Wrap one-liners instead of horizontal-scrolling them — a curl command on
two lines reads better than a scrollbar hiding half the command. */
.install-code pre {
margin: 0;
background: transparent;
white-space: pre;
white-space: pre-wrap;
word-break: break-all;
}
.install-code code {
font-family: var(--vp-font-family-mono);
@@ -245,7 +308,7 @@ async function copy(key: string, text: string) {
padding: 0 10px;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
background: var(--vp-c-bg-alt);
color: var(--vp-c-text-2);
font-size: 0.75rem;
font-family: var(--vp-font-family-base);
@@ -267,19 +330,14 @@ async function copy(key: string, text: string) {
.copy-btn-label {
line-height: 1;
}
.install-note {
font-size: 0.8125rem;
color: var(--vp-c-text-3);
text-align: center;
margin: 1.25rem auto 0;
max-width: 640px;
}
.install-note code {
font-size: 0.8125rem;
}
.install-cta {
text-align: center;
margin-top: 1.75rem;
/* ── Action links ─────────────────────────────────────────────────── */
.install-extra-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1rem !important;
}
.install-cta-link {
display: inline-block;
@@ -295,51 +353,6 @@ async function copy(key: string, text: string) {
background: var(--vp-c-brand-1);
color: var(--vp-c-white);
}
.install-extras {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 1rem;
max-width: 720px;
margin: 2.5rem auto 0;
}
@media (min-width: 720px) {
.install-extras {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
}
.install-extra-card {
min-width: 0;
align-self: start;
background: var(--vp-c-bg-alt);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1.25rem 1.25rem 1rem;
}
.install-extra-card .install-code {
max-width: 100%;
}
.install-extra-card h3 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
border-top: none;
padding-top: 0;
}
.install-extra-card p {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin: 0.5rem 0;
}
.install-extra-card code {
font-size: 0.8125rem;
}
.install-extra-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.9rem !important;
}
.install-extra-secondary {
font-size: 0.85rem;
color: var(--vp-c-text-2);
@@ -352,7 +365,7 @@ async function copy(key: string, text: string) {
border-bottom-color: var(--vp-c-brand-1);
}
/* For AI Agents — sits cleanly below the install-extras grid */
/* For AI Agents — sits cleanly below the path cards */
.agent-section {
max-width: 720px;
margin: 3rem auto 0;
@@ -374,8 +387,6 @@ async function copy(key: string, text: string) {
margin: 0 auto 1.25rem;
}
.agent-prompt-code {
/* Override the install-code single-line style for the multi-line agent
prompt — let it wrap and grow vertically instead of horizontal scroll. */
max-width: 720px;
padding: 18px 58px 18px 22px;
}
@@ -146,6 +146,10 @@ function retargetTo(state: string, nowSec: number) {
}
function onPointerMove(e: PointerEvent) {
// Mouse pointers only. Touch "moves" end when the finger lifts — a lifted
// touch isn't a hover, and its last position would park a phantom cursor
// the eye stares at forever. Touch users get the scroll-gaze baseline.
if (e.pointerType !== 'mouse') return
mouse.x = e.clientX
mouse.y = e.clientY
mouse.valid = true
@@ -226,6 +230,12 @@ function render() {
// already looking straight down at it. Anchors the animation to the reader's
// semantic focus, not to viewport geometry.
const viewportH = window.innerHeight
// Re-query if the cached node was replaced (Vue HMR, route round-trips) —
// a detached element's getBoundingClientRect() is all zeros, which reads as
// installGap = -viewportH and locks the eye staring straight down forever.
if (installEl && !installEl.isConnected) {
installEl = null
}
if (!installEl) {
installEl = document.querySelector<HTMLElement>(INSTALL_SELECTOR)
}
@@ -303,11 +313,15 @@ function render() {
const lookVx = smoothLookVx
const lookVy = smoothLookVy
// Ambient wander — tiny fbm offset so the eye has a little breathing motion
// when neither scroll nor cursor is moving. Small enough it can't compete
// with the coherent target, big enough you feel the sphere's alive.
const wanderX = (fbm(nowSec * 0.05 + 2.1, 7.7, 2) * 2 - 1) * 0.07
const wanderY = (fbm(3.3, nowSec * 0.06 + 11.1, 2) * 2 - 1) * 0.07
// Ambient wander — fbm offset so the eye has breathing motion when neither
// scroll nor cursor is moving. With lightAngleBlend pinned at 1 (below),
// this wander IS the light's ambient life — the core's natural orbit no
// longer contributes. Amplitude eases down as the cursor engages, so hover
// reads as locked attention and idle reads as alive.
const engagement = Math.min(1, Math.max(0, (proximity - 0.75) * 4))
const wanderAmp = 0.12 - 0.07 * engagement
const wanderX = (fbm(nowSec * 0.05 + 2.1, 7.7, 2) * 2 - 1) * wanderAmp
const wanderY = (fbm(3.3, nowSec * 0.06 + 11.1, 2) * 2 - 1) * wanderAmp
// Convert to light angles. The algorithm uses:
// lx = sin(lightAngle1) * 0.65 ly = cos(lightAngle2) * 0.65
@@ -321,10 +335,19 @@ function render() {
const gazeAngleX = Math.asin(tvx)
const gazeAngleY = Math.acos(tvy)
// How hard to lock the light to the bias. proximity is in [0.75, 1.0] now
// (scroll-baseline → cursor-lock), so gazeBlend lives in [0.76, 0.90] —
// always locked to the target. Reduced motion zeroes it.
const gazeBlend = reduceMotion ? 0 : 0.35 + 0.55 * proximity
// Blend MUST be exactly 1 (or 0): the core mixes angles linearly —
// lightAngle = naturalAngle * (1 - blend) + bias * blend
// and naturalAngle = time * lightSpeed grows WITHOUT BOUND. Any partial
// blend leaks (1 - blend) * ω·t into the gaze: a slow continuous orbit
// that drags the eye off the cursor over minutes, plus a visible snap
// whenever proximity moved the blend (two different fractions of a huge
// angle). That was the "tracking gets confused over time / when switching
// between scroll and cursor" bug. At blend 1 the asin/acos mapping also
// collapses to exactly linear (sin(asin(x)) = x), so gaze is
// pixel-faithful; the proximity feel lives in the wander amplitude above
// instead. Reduced motion zeroes the blend (time is frozen there, so the
// natural angle is static anyway).
const gazeBlend = reduceMotion ? 0 : 1
// Intensity + state respond to cursor engagement specifically, not to the
// scroll-watching baseline. Use cursorWeight (raw, not smoothed) so the
@@ -444,13 +467,36 @@ onBeforeUnmount(() => {
margin-top: -32px;
}
.sphere-mark {
/* Floor at 240px (down from 280px) so mobile — where clamp() lands on the
floor because viewport width × 45vw is below it — gets a smaller canvas
and therefore a smaller vertical footprint. Desktop caps at 380px. */
width: clamp(240px, 45vw, 380px);
/* Mobile fills ~78% of the viewport width (floor 280px on very narrow
phones); desktop caps at 380px as before — the wider vw share only
changes what phones and small tablets see. */
width: clamp(280px, 82vw, 380px);
aspect-ratio: 1 / 1;
display: block;
}
/* Tighten the vertical envelope on phones — the canvas carries its own dark
margin (the lit body sits inside the 0.60 baseRadius envelope), so the
wrap can tuck well into the hero above and the How-it-works strip below
without anything visually touching. */
@media (max-width: 640px) {
.sphere-mark-wrap {
margin-top: -64px;
margin-bottom: -28px;
}
}
/* Occlusion halo — the home surface carries the cockpit dot-grid texture,
which visually competes with the sphere's own cell lattice. Paint the page
background back over it: solid behind the sphere body, dimming through the
ring, transparent past the rim. Dark mode only (light has no texture). */
.dark .sphere-mark {
background: radial-gradient(
circle closest-side,
#08090D 0%,
#08090D 52%,
rgba(8, 9, 13, 0.82) 72%,
rgba(8, 9, 13, 0) 100%
);
}
.sphere-mark-canvas {
display: block;
width: 100%;
@@ -0,0 +1,98 @@
<script setup lang="ts">
// "Meet the two surfaces" — sits between the How-it-works strip and the
// Get-started path cards (slotted via theme/index.ts), so readers know what
// the app and the CLI each are before they see install commands.
import { withBase } from 'vitepress'
</script>
<template>
<section class="surface-section" aria-label="The two surfaces">
<div class="surface-label">The two surfaces</div>
<div class="surface-grid">
<div class="surface-card">
<div class="surface-tag">The companion · Android</div>
<h3>Your agent, in your pocket</h3>
<p>A native app that makes your Hermes agent feel local: streaming chat, hands-free voice, and the Manage surface for skills, cron, and profiles. The quick path needs nothing installed on your Hermes — Relay pairing is an optional power-up for terminal and phone control.</p>
<ul>
<li>Streaming chat, multi-Connection, agent profiles, personalities</li>
<li>Voice mode with your server's TTS/STT — or the opt-in Realtime Agent</li>
<li>Google Play track: chat, voice, Manage, terminal/TUI, notifications, media</li>
<li>Sideload track: full Device Control — screen reading, taps, typing — behind a blocklist, confirmations, and auto-disable</li>
</ul>
<a class="cta" :href="withBase('/guide/getting-started')">Install the Android app →</a>
</div>
<div class="surface-card">
<div class="surface-tag">The hand · CLI · Experimental</div>
<h3>Give your agent hands on any machine</h3>
<p>Not another chat app. A single binary you drop on a machine — desktop, laptop, or headless box — so your Hermes agent can <em>work there</em>: filesystem, terminal, processes, clipboard, screenshots — every action consent-gated per device.</p>
<ul>
<li>Agent-callable tools: read, write, patch, search, run, capture</li>
<li><code>daemon</code> mode keeps hands available with no window open</li>
<li>Terminal escape hatch: <code>hermes-relay</code> attaches your server's own TUI when <em>you</em> want to drive</li>
<li>Windows today · macOS / Linux coming soon — one Bun-compiled binary, SHA256-verified install</li>
</ul>
<a class="cta" :href="withBase('/desktop/')">Put a hand on this machine →</a>
</div>
</div>
</section>
</template>
<style scoped>
.surface-section {
max-width: 1152px;
margin: 3rem auto 0;
padding: 0 1.5rem;
}
.surface-label {
text-align: center;
font-family: var(--vp-font-family-mono);
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--vp-c-text-3);
margin-bottom: 1.25rem;
}
.surface-grid {
display: grid;
gap: 1.25rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.surface-grid { grid-template-columns: 1fr 1fr; }
}
.surface-card {
background: var(--vp-c-bg-alt);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 1.5rem;
}
.surface-card h3 {
margin: 0 0 .25rem;
font-size: 1.25rem;
border-top: none;
padding-top: 0;
}
.surface-card .surface-tag {
font-size: .8rem;
font-weight: 500;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--vp-c-text-2);
}
.surface-card p {
margin: .75rem 0;
line-height: 1.55;
color: var(--vp-c-text-2);
}
.surface-card ul {
padding-left: 1.1rem;
margin: .5rem 0 1rem;
color: var(--vp-c-text-2);
}
.surface-card li { margin: .25rem 0; }
.surface-card .cta {
display: inline-block;
font-weight: 500;
margin-top: .5rem;
}
</style>
+125 -95
View File
@@ -1,62 +1,84 @@
/* ==========================================================================
Hermes-Relay — Nothing-Inspired Documentation Theme
Hermes-Relay — Relay Cockpit Documentation Theme
Shared DNA with ARC docs (arc-cli.dev):
Mirrors the Android app's relay cockpit refresh (ui/theme/RelayRefresh.kt):
Fonts: Space Grotesk (body/UI) + Space Mono (code/labels)
Brand: #7C3AED (purple) — single accent
Dark: OLED black (#000), neutral gray surfaces (#111, #1A1A1A)
Light: Off-white (#F5F5F5), white surfaces
No gradients. No shadows. No blur. Flat + border separation.
Dark: navy-black #08090D base, navy panels (#121426 / #191B31 / #22243C),
warm-white ink #F7F6F0, hairline warm-white borders
Light: warm paper #F7F3EA, white surfaces
Accents: Electric indigo — #6E7CFF for links/active text (a brightened
#111DFF that stays readable on navy), #4F5BD5 for fills
(full-strength #111DFF reads as glare on large surfaces — same
lesson as the app's ElectricMuted). Relay periwinkle #AEBFFF
survives only in the dot texture, mirroring the app. Green/amber/
red reserved for status meaning.
Texture: faint 42px grid + 10px Relay dot lattice on the home surface,
mirroring relayGridTexture().
No shadows. No blur. Flat + border separation.
========================================================================== */
/* ── Shared Tokens ─────────────────────────────────────────────────────── */
:root {
/* Brand — purple */
--vp-c-brand-1: #9B6BF0;
--vp-c-brand-2: #7C3AED;
--vp-c-brand-3: #6B35E8;
--vp-c-brand-soft: rgba(124, 58, 237, 0.14);
/* Brand — Electric indigo, muted for contrast on warm paper */
--vp-c-brand-1: #4F5BD5;
--vp-c-brand-2: #4F5BD5;
--vp-c-brand-3: #3A44B8;
--vp-c-brand-soft: rgba(79, 91, 213, 0.12);
/* Fonts — same as ARC */
--vp-font-family-base: 'Space Grotesk', 'DM Sans', system-ui, sans-serif;
--vp-font-family-mono: 'Space Mono', 'JetBrains Mono', 'SF Mono', monospace;
/* Nothing border tokens */
--nd-border: #222222;
--nd-border-visible: #333333;
/* Cockpit hairlines — RelayRefresh.Line / LineStrong (mode-aware) */
--hr-line: #E8E2D5;
--hr-line-strong: #D6CFC0;
/* Status — RelayRefresh.{Green, Amber, Danger} */
--hr-green: #58D36F;
--hr-amber: #F2B14B;
--hr-danger: #FF6B78;
}
/* ── Dark Mode — neutral grays ───────────────────────────────────────── */
/* ── Dark Mode — navy cockpit ────────────────────────────────────────── */
.dark {
--vp-c-bg: #000000;
--vp-c-bg-alt: #111111;
--vp-c-bg-elv: #1A1A1A;
--vp-c-bg-soft: #111111;
/* Electric indigo accents text; Electric muted fills surfaces */
--vp-c-brand-1: #6E7CFF;
--vp-c-brand-2: #4F5BD5;
--vp-c-brand-3: #3A44B8;
--vp-c-brand-soft: rgba(110, 124, 255, 0.16);
--vp-c-divider: #222222;
--vp-c-gutter: #111111;
--hr-line: rgba(247, 246, 240, 0.14);
--hr-line-strong: rgba(247, 246, 240, 0.28);
--vp-c-text-1: #E8E8E8;
--vp-c-text-2: #999999;
--vp-c-text-3: #666666;
--vp-c-bg: #08090D;
--vp-c-bg-alt: #121426;
--vp-c-bg-elv: #191B31;
--vp-c-bg-soft: #121426;
--vp-c-divider: rgba(247, 246, 240, 0.14);
--vp-c-gutter: #121426;
--vp-c-text-1: #F7F6F0;
--vp-c-text-2: #A7A4B7;
--vp-c-text-3: #68647D;
}
/* ── Light Mode — warm off-white ─────────────────────────────────────── */
/* ── Light Mode — warm paper ─────────────────────────────────────────── */
html:not(.dark) {
--vp-c-bg: #F5F5F5;
--vp-c-bg: #F7F3EA;
--vp-c-bg-alt: #FFFFFF;
--vp-c-bg-elv: #FFFFFF;
--vp-c-bg-soft: #F0F0F0;
--vp-c-bg-soft: #F1ECDF;
--vp-c-divider: #E8E8E8;
--vp-c-gutter: #F0F0F0;
--vp-c-divider: #E8E2D5;
--vp-c-gutter: #F1ECDF;
--vp-c-text-1: #1A1A1A;
--vp-c-text-2: #666666;
--vp-c-text-3: #999999;
--vp-c-text-1: #14151F;
--vp-c-text-2: #565264;
--vp-c-text-3: #8B879B;
}
/* ══════════════════════════════════════════════════════════════════════════
@@ -64,17 +86,16 @@ html:not(.dark) {
══════════════════════════════════════════════════════════════════════════ */
.VPNavBar {
border-bottom: 1px solid var(--nd-border) !important;
border-bottom: 1px solid var(--hr-line) !important;
}
.dark .VPNavBar {
background: #000000 !important;
background: #08090D !important;
backdrop-filter: none !important;
}
html:not(.dark) .VPNavBar {
background: #F5F5F5 !important;
border-bottom-color: #E8E8E8 !important;
background: #F7F3EA !important;
backdrop-filter: none !important;
}
@@ -96,15 +117,23 @@ html:not(.dark) .VPNavBar {
text-transform: uppercase !important;
}
/* Search box: align with Nothing aesthetic */
/* Search box: align with cockpit aesthetic */
.VPNavBarSearch .DocSearch-Button {
border: 1px solid var(--nd-border-visible) !important;
border: 1px solid var(--hr-line-strong) !important;
background: transparent !important;
border-radius: 4px !important;
}
.dark .VPNavBarSearch .DocSearch-Button {
border-color: var(--nd-border-visible) !important;
/* ══════════════════════════════════════════════════════════════════════════
HOME SURFACE — cockpit grid texture (mirrors relayGridTexture())
══════════════════════════════════════════════════════════════════════════ */
.dark .VPHome {
background-image:
linear-gradient(rgba(247, 246, 240, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(247, 246, 240, 0.016) 1px, transparent 1px),
radial-gradient(circle, rgba(174, 191, 255, 0.11) 1.1px, transparent 1.4px);
background-size: 42px 42px, 42px 42px, 10px 10px;
}
/* ══════════════════════════════════════════════════════════════════════════
@@ -131,6 +160,21 @@ html:not(.dark) .VPNavBar {
line-height: 1.6;
}
/* Platform-availability note under the hero action buttons */
.hero-platform-note {
margin: 14px 0 0;
font-family: var(--vp-font-family-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--vp-c-text-3);
}
@media (max-width: 959px) {
.hero-platform-note {
text-align: center;
}
}
/* Hero image — flat, border separation */
.VPHero .image-container .image-bg {
display: none !important;
@@ -158,23 +202,18 @@ html:not(.dark) .VPNavBar {
}
/* ══════════════════════════════════════════════════════════════════════════
FEATURE CARDS — subtle border, hover border only
FEATURE CARDS — navy panel, hairline border, hover border only
══════════════════════════════════════════════════════════════════════════ */
.VPFeature {
border: 1px solid var(--nd-border) !important;
background: var(--vp-c-bg) !important;
border: 1px solid var(--hr-line) !important;
background: var(--vp-c-bg-alt) !important;
border-radius: 8px !important;
transition: border-color 0.2s cubic-bezier(0.25, 0.1, 0.25, 1) !important;
}
html:not(.dark) .VPFeature {
border-color: #E8E8E8 !important;
background: #FFFFFF !important;
}
.VPFeature:hover {
border-color: var(--nd-border-visible) !important;
border-color: var(--hr-line-strong) !important;
}
.VPFeature .title {
@@ -221,15 +260,11 @@ html:not(.dark) .VPFeature {
}
.VPButton.alt {
border-color: var(--nd-border-visible) !important;
border-color: var(--hr-line-strong) !important;
background: transparent !important;
transition: border-color 0.2s cubic-bezier(0.25, 0.1, 0.25, 1) !important;
}
html:not(.dark) .VPButton.alt {
border-color: #CCCCCC !important;
}
.VPButton.alt:hover {
border-color: var(--vp-c-text-2) !important;
}
@@ -240,14 +275,7 @@ html:not(.dark) .VPButton.alt {
.VPSidebar {
background: var(--vp-c-bg) !important;
}
.dark .VPSidebar {
border-right: 1px solid var(--nd-border) !important;
}
html:not(.dark) .VPSidebar {
border-right: 1px solid #E8E8E8 !important;
border-right: 1px solid var(--hr-line) !important;
}
.VPSidebarItem.is-active > .item > .link > .text {
@@ -264,7 +292,7 @@ html:not(.dark) .VPSidebar {
}
/* ══════════════════════════════════════════════════════════════════════════
CODE BLOCKS — near-black, border separation
CODE BLOCKS — sunken navy panel, border separation
══════════════════════════════════════════════════════════════════════════ */
.vp-code-group .tabs label.active {
@@ -272,15 +300,15 @@ html:not(.dark) .VPSidebar {
}
.dark .vp-doc div[class*='language-'] {
background: #0A0A0A !important;
border: 1px solid var(--nd-border);
border-radius: 4px;
background: #0B0C12 !important;
border: 1px solid var(--hr-line);
border-radius: 8px;
}
html:not(.dark) .vp-doc div[class*='language-'] {
background: #FFFFFF !important;
border: 1px solid #E8E8E8;
border-radius: 4px;
border: 1px solid var(--hr-line);
border-radius: 8px;
}
/* ══════════════════════════════════════════════════════════════════════════
@@ -310,44 +338,54 @@ html:not(.dark) .vp-doc div[class*='language-'] {
}
/* ══════════════════════════════════════════════════════════════════════════
INLINE CODE
INLINE CODE — periwinkle on navy
══════════════════════════════════════════════════════════════════════════ */
.dark .vp-doc :not(pre) > code {
background: #1A1A1A;
color: #C4B5FD;
background: #191B31;
color: #9FACFF;
}
html:not(.dark) .vp-doc :not(pre) > code {
background: #F0F0F0;
color: #5B21B6;
background: #EDE8DC;
color: #3A44B8;
}
/* ══════════════════════════════════════════════════════════════════════════
BADGES / TIPS / WARNINGS — restrained
BADGES / TIPS / WARNINGS — restrained, status colors mean status
══════════════════════════════════════════════════════════════════════════ */
.VPBadge.tip {
background-color: var(--vp-c-brand-soft) !important;
color: #C4B5FD !important;
color: #9FACFF !important;
font-family: var(--vp-font-family-mono) !important;
font-size: 11px !important;
letter-spacing: 0.04em !important;
text-transform: uppercase !important;
}
html:not(.dark) .VPBadge.tip {
color: #3A44B8 !important;
}
.dark .vp-doc .custom-block.tip {
border-color: rgba(124, 58, 237, 0.3);
background: rgba(124, 58, 237, 0.04);
border-color: rgba(110, 124, 255, 0.35);
background: rgba(110, 124, 255, 0.05);
}
.dark .vp-doc .custom-block.warning {
background: rgba(234, 179, 8, 0.04);
border-color: rgba(242, 177, 75, 0.3);
background: rgba(242, 177, 75, 0.04);
}
.dark .vp-doc .custom-block.danger {
border-color: rgba(255, 107, 120, 0.3);
background: rgba(255, 107, 120, 0.04);
}
html:not(.dark) .vp-doc .custom-block.tip {
border-color: rgba(124, 58, 237, 0.3);
background: rgba(124, 58, 237, 0.04);
border-color: rgba(79, 91, 213, 0.3);
background: rgba(79, 91, 213, 0.04);
}
/* ══════════════════════════════════════════════════════════════════════════
@@ -355,11 +393,7 @@ html:not(.dark) .vp-doc .custom-block.tip {
══════════════════════════════════════════════════════════════════════════ */
.VPFooter {
border-top: 1px solid var(--nd-border) !important;
}
html:not(.dark) .VPFooter {
border-top-color: #E8E8E8 !important;
border-top: 1px solid var(--hr-line) !important;
}
/* ══════════════════════════════════════════════════════════════════════════
@@ -367,11 +401,11 @@ html:not(.dark) .VPFooter {
══════════════════════════════════════════════════════════════════════════ */
::-webkit-scrollbar { width: 4px; }
.dark ::-webkit-scrollbar-track { background: #000000; }
.dark ::-webkit-scrollbar-thumb { background: var(--nd-border-visible); border-radius: 2px; }
.dark ::-webkit-scrollbar-thumb:hover { background: #666666; }
html:not(.dark) ::-webkit-scrollbar-track { background: #F5F5F5; }
html:not(.dark) ::-webkit-scrollbar-thumb { background: #CCCCCC; border-radius: 2px; }
.dark ::-webkit-scrollbar-track { background: #08090D; }
.dark ::-webkit-scrollbar-thumb { background: var(--hr-line-strong); border-radius: 2px; }
.dark ::-webkit-scrollbar-thumb:hover { background: #68647D; }
html:not(.dark) ::-webkit-scrollbar-track { background: #F7F3EA; }
html:not(.dark) ::-webkit-scrollbar-thumb { background: #D6CFC0; border-radius: 2px; }
/* ══════════════════════════════════════════════════════════════════════════
DOC FOOTER CTA — per-page links
@@ -388,14 +422,10 @@ html:not(.dark) ::-webkit-scrollbar-thumb { background: #CCCCCC; border-radius:
.doc-footer-cta hr {
border: none;
border-top: 1px solid var(--nd-border);
border-top: 1px solid var(--hr-line);
margin-bottom: 16px;
}
html:not(.dark) .doc-footer-cta hr {
border-top-color: #E8E8E8;
}
.doc-footer-cta a {
color: var(--vp-c-brand-1);
}
+5 -1
View File
@@ -8,6 +8,8 @@ import HeroDemo from './components/HeroDemo.vue';
import FeatureMatrix from './components/FeatureMatrix.vue';
import SphereMark from './components/SphereMark.vue';
import ExperimentalBadge from './components/ExperimentalBadge.vue';
import HowItWorks from './components/HowItWorks.vue';
import SurfaceCards from './components/SurfaceCards.vue';
export default {
extends: DefaultTheme,
@@ -19,7 +21,9 @@ export default {
Layout() {
return h(DefaultTheme.Layout, null, {
'home-hero-image': () => h(HeroDemo),
'home-hero-after': () => [h(SphereMark), h(InstallSection)],
'home-hero-actions-after': () =>
h('p', { class: 'hero-platform-note' }, 'CLI: Windows today · macOS / Linux coming soon'),
'home-hero-after': () => [h(SphereMark), h(HowItWorks), h(SurfaceCards), h(InstallSection)],
'doc-after': () =>
h('div', { class: 'doc-footer-cta' }, [
h('hr'),
+1 -1
View File
@@ -107,7 +107,7 @@ Auth uses optional Bearer token (`API_SERVER_KEY`). Most local setups run withou
## ADR-7: Pairing Code Auth for Relay (QR-driven, updated 2026-04-11)
**Decision:** Initial pairing via 6-char code generated by the pair command (`/hermes-relay-pair` skill or `hermes-pair` shell shim) on the Hermes host, pre-registered with the relay via a loopback-only `/pairing/register` endpoint, and embedded in the same QR payload that carries the API server credentials. One scan configures both chat and the relay. Session tokens handle all subsequent reconnects.
**Decision:** Initial Relay pairing via 6-char code generated by the pair command (`hermes pair`, `/hermes-relay-pair`, or the compatibility `hermes-pair` shell shim) on the Hermes host, pre-registered with the relay via a loopback-only `/pairing/register` endpoint, and embedded in the same QR payload that can also carry API server credentials. Standard API/dashboard setup can be saved without Relay pairing. Session tokens handle all subsequent relay reconnects.
### Rationale
+1 -1
View File
@@ -70,7 +70,7 @@ The relay connection (bridge/terminal) uses a pairing code for initial setup, th
<HermesFlow diagram="auth-flow" height="200px" />
Pairing codes use the full `A-Z / 0-9` alphabet (36 chars). The pair command (`/hermes-relay-pair` skill or `hermes-pair` shell shim) on the Hermes host mints the code and pre-registers it with the relay via a loopback-only `/pairing/register` endpoint before embedding it in the QR — so the phone never types a code by hand. Session tokens are stored in EncryptedSharedPreferences backed by Android Keystore.
Pairing codes use the full `A-Z / 0-9` alphabet (36 chars). The pair command (`hermes pair`, `/hermes-relay-pair`, or the compatibility `hermes-pair` shell shim) on the Hermes host mints the code and pre-registers it with the relay via a loopback-only `/pairing/register` endpoint before embedding it in the QR — so the phone never types a code by hand. Session tokens are stored in EncryptedSharedPreferences backed by Android Keystore.
## Direct API vs Relay
+2 -2
View File
@@ -1,6 +1,6 @@
# Privacy & Data Handling
Hermes-Relay is a self-hosted app. It connects only to your own servers — no cloud accounts, no hosted Hermes-Relay backend, no ads, and no third-party analytics.
Hermes-Relay connects only to your own machines — no cloud accounts, no hosted Hermes-Relay backend, no ads, and no third-party analytics.
## Track split
@@ -38,7 +38,7 @@ The Google Play build can request camera for QR pairing, microphone for Voice mo
## Data export and reset
From **Settings**, you can export your configuration (secrets excluded), import a backup, or perform a full reset that wipes local data including encrypted credentials.
From **Settings**, you can export a full connection backup, import a backup, or perform a full reset that wipes local data including encrypted credentials. Full backups include sensitive connection material such as API keys, relay session tokens, device IDs, and dashboard cookies so restored connections can work without manual re-entry. Keep exported backup files private.
## Open source
+6 -3
View File
@@ -5,7 +5,8 @@
API keys are stored using Android's `EncryptedSharedPreferences`:
- Encryption: AES-256-GCM
- Key management: Android Keystore (hardware-backed when available)
- Keys are never included in backups or exports
- Full backups include API keys and other connection secrets. Treat exported
backup files like credentials and store them somewhere private.
## Network Security
@@ -38,7 +39,7 @@ The Hermes API bearer token is accepted only for `/voice/config`, `/voice/transc
## Relay Auth Flow
1. Operator runs `/hermes-relay-pair` (from any Hermes chat surface) or `hermes-pair` (shell shim) on the Hermes host
1. Operator runs `hermes pair` (shell), `/hermes-relay-pair` (from any Hermes chat surface), or the compatibility `hermes-pair` shim on the Hermes host
2. The pair command probes `localhost:RELAY_PORT/health`; if the relay is up, it mints a fresh 6-char code
3. It pre-registers the code with the relay via the loopback-only `POST /pairing/register` endpoint
4. The relay URL + code are embedded in the QR payload alongside the API server credentials
@@ -62,7 +63,9 @@ Pairing codes use the full `A-Z / 0-9` alphabet (36 chars). The earlier "no ambi
- Session tokens encrypted in EncryptedSharedPreferences
- API keys never logged or included in error messages
- Backup exports exclude tokens and API keys
- Backup exports include API keys, relay session tokens, device IDs, and
dashboard cookies so connections can be restored. The export dialog warns
before writing the file.
- DataStore preferences are app-private (standard Android sandbox)
## Bridge Security — Five-Stage Safety Gate
+4 -4
View File
@@ -4,7 +4,7 @@
You don't need to. Native local-Hermes already has the same `/paste`, `/image <path>`, drag-drop a file from Explorer, and `Alt+V` paths — they ship in upstream hermes-agent and the Ink TUI directly. Hermes-Relay is specifically for the **remote-server** case: when you want to use a Hermes that lives somewhere else (a home server, a GPU box, a cloud VM) from your laptop, with the same UX as a local install. If everything's already on the same machine, run `hermes` directly and skip the relay.
The two paths are complements, not alternatives. You'd use Hermes-Relay's desktop CLI when:
The two paths are complements, not alternatives. You'd use the Hermes-Relay CLI when:
- The agent's compute, models, or secrets need to live somewhere other than your daily-driver laptop.
- You want the same agent / sessions / memory accessible from multiple devices.
- You're sharing a GPU or model API key across machines.
@@ -26,7 +26,7 @@ You'd use SSH *instead* of this if you want to type commands yourself. You'd use
## Can I use it offline?
No. The whole point is the agent lives on a server reachable over the network. If your laptop is offline, you can't reach the Hermes host. (You can of course run Hermes itself on your laptop and skip the relay — but then the desktop CLI is redundant.)
No. The whole point is the agent lives on a server reachable over the network. If your laptop is offline, you can't reach the Hermes host. (You can of course run Hermes itself on your laptop and skip the relay — but then the CLI is redundant.)
## How do I revoke access from one of my machines?
@@ -125,7 +125,7 @@ v1.0. Tracked in [ROADMAP.md](https://github.com/Codename-11/hermes-relay/blob/m
Until then: keep a `hermes-relay` session open in a spare terminal tab for the agent to dispatch tool calls into.
## Can multiple people use the same Hermes host from different desktop CLIs?
## Can multiple people use the same Hermes host from different CLI clients?
Right now the server tracks a single "active" desktop client per relay — if you pair from two machines, the most recently connected wins routing. v1.0 adds per-session-token routing (each hermes session binds to a specific desktop client) so multi-client is clean.
@@ -133,7 +133,7 @@ For now: one desktop attached at a time. Or two if you pair them with different
## Is there voice mode?
Not in the desktop CLI. The Android client has voice mode. The CLI is text-first.
Not in the CLI. The Android client has voice mode. The CLI is text-first.
## Is there a Windows-on-ARM build?
+31 -26
View File
@@ -1,16 +1,28 @@
# Desktop CLI <ExperimentalBadge />
# Hermes-Relay CLI <ExperimentalBadge />
**Run Hermes on your server. Use it from your laptop. It feels native.**
**A hand for your agent, on any computer you pair.**
`hermes-relay` is a single-binary desktop client that talks to a server-deployed Hermes agent over WSS — same shell, same Ink TUI, same `/paste` an image into the conversation, same session continuity. The agent brain (LLM, tools, memory, sessions) stays on your Hermes host. Your laptop is the keyboard and the eyes.
`hermes-relay` is a single binary you drop on a machine — desktop, laptop, or headless box — so your Hermes agent can work there: read and write files, search a codebase, run commands, manage processes, read the clipboard, capture screenshots — all over the same WSS relay, all consent-gated per device. The brain (LLM, tools, memory, sessions) never leaves your Hermes host. This binary is the hand it reaches with.
It also includes a terminal escape hatch for when *you* want to drive: bare `hermes-relay` attaches your server's own Hermes TUI over a PTY, tmux-backed so disconnects lose nothing.
::: warning Experimental phase
Binaries are unsigned (SmartScreen / Gatekeeper warnings are expected — the installers print the escape hatches). Wire protocol may shift between alphas. Multi-client routing is single-client MVP. Code-signed releases land with v1.0. Safe to use today — just expect occasional friction and [file an issue](https://github.com/Codename-11/hermes-relay/issues) when you hit one.
**Windows today — macOS / Linux builds coming soon.** Binaries are unsigned (SmartScreen warnings are expected — the installer prints the escape hatch). Wire protocol may shift between alphas. Multi-client routing is single-client MVP. Code-signed releases land with v1.0. Safe to use today — just expect occasional friction and [file an issue](https://github.com/Codename-11/hermes-relay/issues) when you hit one.
:::
## The killer demo — paste a screenshot in your TUI
::: info Where this track is headed
This surface is focusing into a **remote-hands connector** — remote control, filesystem, and terminal access for the agent on machines you install it to. Desktop chat and management UX belong to [hermes-desktop](https://github.com/NousResearch/hermes-agent); this CLI's `chat` mode keeps working for scripting but isn't where new features land. "Desktop" is shorthand, not a constraint — the same binary runs on laptops and headless servers (`daemon` mode needs no display at all), and the track naming will be reframed to match in a later refactor.
:::
This is the experience that earns the "feels-native" claim. You are inside `hermes-relay` (bare invocation drops you straight into the Hermes Ink TUI over a PTY — no subcommand needed), talking to your remote Hermes the same way you would a local one.
## The point — the agent works on *your* machine
Ask your agent — from your phone, from the attached TUI, from anywhere — to "check whether that build passes on my desktop" or "grab the error from my clipboard," and it reaches through the relay to do it: read your notes, grep your codebase, run a build, patch a file, capture a screenshot — while the brain and conversation state stay on the host. [Read how →](./tools.md)
This mirrors how the Android client hands the agent `android_tap` / `android_screenshot`. Zero hermes-agent core changes — the `desktop_*` tools register via the standard plugin system, same pattern as `android_*`. Run `hermes-relay daemon` and the hand stays available with no window open.
## Demo — native paste into the attached TUI
The escape hatch earns its keep too. You are inside `hermes-relay` (bare invocation drops you straight into the Hermes Ink TUI over a PTY — no subcommand needed), talking to your remote Hermes the same way you would a local one.
```text
Win+Shift+S # Windows snipping tool — screenshot goes to the clipboard
@@ -23,7 +35,7 @@ type your prompt # Send normally. The vision-capable model sees image + text
# in one turn.
```
Identical UX to native local-Hermes paste. That's the whole pitch. The clipboard read happens on your machine, the file lives on the server, the model sees both — one round-trip, no SSH, no SCP, no manual upload.
Identical UX to native local-Hermes paste — and a small taste of the hands model: the clipboard read happens on your machine, the file lives on the server, the model sees both. One round-trip, no SSH, no SCP, no manual upload.
The same chord set works on macOS (`Cmd+Shift+4` → screenshot to clipboard → `Ctrl+A v`) and on Linux (Wayland `wl-paste` / X11 `xclip` are detected automatically).
@@ -31,10 +43,10 @@ The same chord set works on macOS (`Cmd+Shift+4` → screenshot to clipboard →
| Mode | Command | Best for |
|------|---------|----------|
| **Shell** (default) | `hermes-relay` | Full Hermes Ink TUI over a PTY — banner, Victor, slash commands, the whole experience. Uses tmux on the host so disconnects preserve state. |
| **Chat (structured)** | `hermes-relay chat "<prompt>"` / `hermes-relay "<prompt>"` | Scriptable, one-shot, pipes stdin. `--json` emits `GatewayEvent`s per line for `jq` / automation. REPL supports `/paste`, `/screenshot`, `/image <path>`. |
| **Tools** | Automatic, in-session | The remote agent can call `desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch`, `desktop_clipboard_read/write`, `desktop_screenshot`, `desktop_open_in_editor` — **executed on your machine**, not the server. One-time per-URL consent gate. |
| **Tools (the hand)** | Automatic, in-session | The remote agent can call `desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch`, `desktop_clipboard_read/write`, `desktop_screenshot`, `desktop_open_in_editor` — **executed on your machine**, not the server. One-time per-URL consent gate. |
| **Daemon** | `hermes-relay daemon` | Headless tool router. Keeps the agent's hands available even when no shell is open. JSON-line lifecycle logs. |
| **Shell** (default) | `hermes-relay` | The escape hatch: full Hermes Ink TUI over a PTY — banner, Victor, slash commands, the whole experience. Uses tmux on the host so disconnects preserve state. |
| **Chat (structured)** | `hermes-relay chat "<prompt>"` / `hermes-relay "<prompt>"` | Scriptable, one-shot, pipes stdin. `--json` emits `GatewayEvent`s per line for `jq` / automation. Maintained for scripting; not a growth surface. |
| **Surface plugins** | `hermes-relay plugins` | Install and launch terminal dashboard surfaces. Herm is built in as an installable `herm-tui` plugin with external-terminal and embedded-tray launch paths. |
| **Pair / Sessions / Status / Tools / Devices / Doctor / Update / Workspace / Paste** | `hermes-relay <verb>` | First-time setup, TUI tmux session management, session inventory, server-side toolset introspection, paired-device management, local diagnostics, self-update, workspace-context inspection, one-shot clipboard staging. See [Subcommands](./subcommands.md). |
@@ -46,7 +58,7 @@ While inside the shell/TUI session (bare `hermes-relay`, the default mode), `Ctr
|-------|--------|
| `Ctrl+A .` | Detach cleanly. tmux session persists on the server; next `hermes-relay` re-attaches with full state. |
| `Ctrl+A k` | Destroy the tmux session. Fresh hermes on next run. |
| `Ctrl+A v` | [Stage clipboard image to server inbox + auto-type `/paste`](#the-killer-demo-paste-a-screenshot-in-your-tui). |
| `Ctrl+A v` | [Stage clipboard image to server inbox + auto-type `/paste`](#demo-native-paste-into-the-attached-tui). |
| `Ctrl+A ?` (or `Ctrl+A h`) | Re-print the chord-help banner. The attach-time banner scrolls off as soon as anything writes — this is the way back. |
| `Ctrl+A Ctrl+A` | Forward a literal `Ctrl+A` (for nested tmux). |
@@ -55,7 +67,6 @@ While inside the shell/TUI session (bare `hermes-relay`, the default mode), `Ctr
## Headline features
- **[Native paste / screenshot / image](./subcommands.md)** — the chord set above, plus REPL slash commands `/paste`, `/screenshot`, `/screenshot primary`, `/screenshot 1`, `/image <path>`. Multi-monitor aware: `/screenshot` defaults to the virtual-screen union; `primary` / a 1-indexed display narrows. Identical wire format to a local Hermes paste.
- **Tray Chat** — the desktop dashboard has a Chat tab. Paired installs stream through the saved relay session; unpaired/chat-only installs can enter a direct Hermes WebAPI URL and optional in-memory API key.
- **[Local tool routing](./tools.md)** — agent-callable file I/O, shell exec, ripgrep, clipboard, screenshot, editor-launcher, and unified-diff patching. Strict consent gate per relay URL; non-TTY stdin fails closed.
- **[Self-update](./subcommands.md#hermes-relay-update)** — `hermes-relay update` polls GitHub Releases, semver-compares, downloads + verifies SHA256, and atomic-swaps the binary. POSIX renames in place; Windows uses cooperative `.new.exe` swap on next start.
- **[Surface plugins](./subcommands.md#hermes-relay-plugins)** — install, update, launch, or embed terminal dashboard plugins from the tray or CLI. The first built-in plugin is [Herm](https://github.com/liftaris/herm), installed as `herm-tui` and resumed with `herm -c`.
@@ -68,13 +79,13 @@ While inside the shell/TUI session (bare `hermes-relay`, the default mode), `Ctr
- **[Reconnect-on-drop + TOFU cert pinning](./pairing.md)** — exponential backoff (1 s → 30 s, 5 min on 429), per-host SPKI sha256 pin captured first-time and verified every reconnect.
- **[Bun-compiled native binary, no Node required](./installation.md)** — curl/irm one-liners install a self-contained binary. Version-aware (`upgrading X → Y` readback), collision-safe `hermes` alias, `~/.hermes/bin/` on PATH.
## When to use the desktop CLI vs. installing Hermes locally
## When to use the CLI vs. installing Hermes locally
Both are valid. Pick based on where the agent's compute, models, and state should live.
| Setup | When it fits | What lives where |
|-------|--------------|------------------|
| **Server-deployed Hermes + Hermes-Relay desktop CLI** | Multiple devices, shared sessions, GPU on a different box, model API keys you don't want spread across machines. | Compute, models, secrets, sessions, memory all live on the Hermes host. The CLI is a thin client. Pair from laptop, desktop, work box — same agent, shared state. |
| **Hermes on its own host + Hermes-Relay CLI** | Multiple devices, shared sessions, GPU on a different box, model API keys you don't want spread across machines. | Compute, models, secrets, sessions, memory all live on the Hermes host. The CLI is a thin client. Pair from laptop, desktop, work box, headless server — same agent, shared state. |
| **Native local Hermes install** | Single machine, willing to manage Python venv + model API keys yourself, no cross-device session continuity needed. | Everything on your laptop. Model API calls go directly from your machine. No relay involved. |
Hermes-Relay is for the first case. If you're in the second case, you don't need this CLI at all — just install hermes-agent and use `hermes` directly. The two paths are complements, not alternatives.
@@ -89,7 +100,7 @@ hermes-relay pair --remote ws://<host>:8767
hermes-relay
```
```bash [macOS / Linux]
```bash [macOS / Linux — coming soon]
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
hermes-relay pair --remote ws://<host>:8767
hermes-relay
@@ -97,31 +108,25 @@ hermes-relay
:::
The third command (`hermes-relay` with no args) drops you into `shell` mode — the full Hermes TUI verbatim, running in tmux on the server. Try `Ctrl+A v` after a `Win+Shift+S` (or `Cmd+Shift+4`) and you'll see the killer demo in action.
The third command (`hermes-relay` with no args) drops you into `shell` mode — the full Hermes TUI verbatim, running in tmux on the server. Try `Ctrl+A v` after a `Win+Shift+S` and you'll see the [native-paste demo](#demo-native-paste-into-the-attached-tui) in action.
See **[Installation](./installation.md)** for the full walkthrough (Bun-compiled binaries, version-aware install, `hermes` alias, self-update flow) and **[Pairing](./pairing.md)** for minting a 6-char code on the server.
The tray app follows the same rule as the CLI for relay-backed desktop control: daemon, devices, grants, and TUI controls unlock only after a paired session token exists in `~/.hermes/remote-sessions.json`. A raw Advanced relay URL is only an override hint, not a pairing, so fresh or signed-out installs show "Pair first" for those controls until pairing completes. The Chat tab is allowed to run in a lighter chat-only mode against a direct Hermes WebAPI URL (`http://host:8642`) with an optional API key kept only for the current tray session. The TUI tab runs the experimental embedded terminal: xterm.js renders inside the dashboard while the Rust tray process owns the local PTY and launches the same tmux-backed `hermes-relay` session path. The Plugins tab uses the same embedded terminal host for installable dashboard surfaces such as Herm. Open in an external terminal remains the fallback for PTY focus, resize, and shortcut testing.
The tray app is a control surface for the hand, not a chat app (chat and management UX live in [hermes-desktop](https://github.com/NousResearch/hermes-agent)). It follows the same rule as the CLI for relay-backed control: daemon, devices, grants, and TUI controls unlock only after a paired session token exists in `~/.hermes/remote-sessions.json`. A raw Advanced relay URL is only an override hint, not a pairing, so fresh or signed-out installs show "Pair first" for those controls until pairing completes. The TUI tab runs the experimental embedded terminal: xterm.js renders inside the dashboard while the Rust tray process owns the local PTY and launches the same tmux-backed `hermes-relay` session path. The Plugins tab uses the same embedded terminal host for installable dashboard surfaces such as Herm. Open in an external terminal remains the fallback for PTY focus, resize, and shortcut testing.
## Why both shell AND chat modes?
They're not the same thing:
- **`shell`** pipes the host's actual `hermes` CLI through a PTY. You see exactly what `ssh bailey@hermes-host hermes` would show — same banner, same skin, same slash commands. Best for interactive use.
- **`chat`** speaks the relay's structured `tui` channel (JSON-RPC-over-WSS), renders events as plain lines. Scriptable, pipeable, survives non-TTY environments. The REPL still has slash-command paste / screenshot / image attach. Best for automation / CI / one-shot queries.
- **`chat`** speaks the relay's structured `tui` channel (JSON-RPC-over-WSS), renders events as plain lines. Scriptable, pipeable, survives non-TTY environments. Best for automation / CI / one-shot queries.
Most users want `shell`. If you're writing a script, use `chat --json`.
## Local tool routing — the big deal
The agent on the server can reach through the relay and run tools on **your** machine — read your notes, grep your codebase, run a build, edit a file, capture a screenshot, read your clipboard — while the agent's brain + conversation state stay on the host. [Read how →](./tools.md)
This mirrors how the Android client exposes `android_tap` / `android_screenshot` to the agent. Zero hermes-agent core changes — the `desktop_*` tools are registered via the standard plugin system, same pattern as `android_*`.
Use `shell` when you want to drive interactively; use `chat --json` from scripts. Chat mode is maintained for automation — it isn't where new features land, and it isn't a desktop chat app (that's [hermes-desktop](https://github.com/NousResearch/hermes-agent)'s job).
## Related
- [Hermes-Relay Android client](/guide/) — same project, same relay, different surface (phone control, voice, bridge).
- [Hermes Agent](https://github.com/NousResearch/hermes-agent) — the agent platform the CLI talks to.
- [Herm](https://github.com/liftaris/herm) — OpenTUI dashboard plugin installable from the desktop surface.
- [Desktop CLI GitHub source](https://github.com/Codename-11/hermes-relay/tree/main/desktop) — `@hermes-relay/cli` package.
- [CLI GitHub source](https://github.com/Codename-11/hermes-relay/tree/main/desktop) — `@hermes-relay/cli` package.
- [Release notes](https://github.com/Codename-11/hermes-relay/releases?q=desktop) — tagged `desktop-v*` (separate track from Android).
+12 -8
View File
@@ -1,6 +1,6 @@
# Installing the Desktop CLI <ExperimentalBadge />
# Installing the CLI <ExperimentalBadge />
One-liner installs on Windows / macOS / Linux. The installer downloads a prebuilt single-file binary — **no Node required, no Python required**.
One-liner install on **Windows today — macOS / Linux builds coming soon**. The installer downloads a prebuilt single-file binary — **no Node required, no Python required**.
## Prerequisites
@@ -21,8 +21,8 @@ The script:
2. Resolves the **latest** desktop release by querying the GitHub Releases API directly and picking the SemVer-max `desktop-v*` tag — prereleases included, so alpha builds aren't skipped (see CHANGELOG entry on alpha.11 for why this matters).
3. Downloads `hermes-relay-win-x64.exe` and verifies SHA256 against the published `SHA256SUMS.txt`.
4. Reads the existing binary's `--version` (if present) and prints one of:
- `existing install detected: 0.3.0-alpha.13 — upgrading to 0.3.0-alpha.14`
- `reinstalling 0.3.0-alpha.14`
- `existing install detected: 0.3.0-alpha.17 — upgrading to 0.3.0-alpha.18`
- `reinstalling 0.3.0-alpha.18`
- `installing fresh`
5. Installs to `%USERPROFILE%\.hermes\bin\hermes-relay.exe`.
6. Drops a `hermes.cmd` shim next to the binary so `hermes <prompt>` works as a short alias — **collision-safe**: if a `hermes` already exists in the install dir (e.g. from a local hermes-agent install), the installer leaves it untouched and prints a skip notice.
@@ -49,11 +49,11 @@ Code signing (EV cert) is a v1.0 milestone — the experimental phase doesn't ju
### Pin a specific version
```powershell
$env:HERMES_RELAY_VERSION = 'desktop-v0.3.0-alpha.14'
$env:HERMES_RELAY_VERSION = 'desktop-v0.3.0-alpha.18'
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
The version-aware readback compares full SemVer including the prerelease tail, so `desktop-v0.3.0-alpha.13` → `desktop-v0.3.0-alpha.14` is recognized as an upgrade rather than a reinstall.
The version-aware readback compares full SemVer including the prerelease tail, so `desktop-v0.3.0-alpha.17` → `desktop-v0.3.0-alpha.18` is recognized as an upgrade rather than a reinstall.
### Uninstall
@@ -61,6 +61,10 @@ See [Uninstall](#uninstall) below — the PowerShell one-liner reverses everythi
## macOS / Linux — curl one-liner
::: warning Coming soon
macOS / Linux binaries aren't published yet — the installer script below is ready and documented ahead of those builds. Watch [Releases](https://github.com/Codename-11/hermes-relay/releases) for the first `darwin`/`linux` assets.
:::
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
```
@@ -103,7 +107,7 @@ Apple Developer ID signing + notarization is a v1.0 milestone.
### Pin a specific version
```bash
HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.14 \
HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.18 \
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
```
@@ -151,7 +155,7 @@ npx tsx src/cli.ts --help
npx tsx src/cli.ts pair --remote ws://<host>:8767
```
The package name in `desktop/package.json` is local workspace metadata today. The desktop CLI is not published to npm; use GitHub Release binaries or a local clone with `npm link`.
The package name in `desktop/package.json` is local workspace metadata today. The CLI is not published to npm; use GitHub Release binaries or a local clone with `npm link`.
## Uninstall
+6 -6
View File
@@ -7,7 +7,7 @@ Pairing exchanges a one-time 6-character code for a long-lived session token, st
SSH into your Hermes host (or use any terminal already on it):
```bash
hermes-pair --ttl 600
hermes pair --ttl 600
```
Output:
@@ -38,7 +38,7 @@ The CLI prompts:
```
Relay: ws://<host>:8767
Need a pairing code — run `/hermes-relay-pair` (or `hermes-pair`) on the relay host.
Need a pairing code — run `hermes pair` (or `/hermes-relay-pair`) on the relay host.
(Paste works; cleaned code shown before submit.)
Pairing code (6 chars): _
@@ -75,12 +75,12 @@ On the server:
```bash
# All three routes
hermes-pair --mode auto --public-url https://hermes.example.com
hermes pair --mode auto --public-url https://hermes.example.com
# Or specific:
hermes-pair --mode lan
hermes-pair --mode tailscale
hermes-pair --mode public --public-url https://hermes.example.com
hermes pair --mode lan
hermes pair --mode tailscale
hermes pair --mode public --public-url https://hermes.example.com
```
The output is a JSON blob (printed alongside the QR). Copy it verbatim and paste to the CLI:
+1 -1
View File
@@ -183,7 +183,7 @@ hermes-relay tools --remote <url> --json # machine-readable
## `hermes-relay devices`
Server-side paired-device management (the relay's `GET/DELETE/PATCH /sessions` HTTP API). Shows all paired devices across all your clients — Android phones, desktop CLIs, Ink TUIs — and lets you revoke or extend them.
Server-side paired-device management (the relay's `GET/DELETE/PATCH /sessions` HTTP API). Shows all paired devices across all your clients — Android phones, CLI clients, Ink TUIs — and lets you revoke or extend them.
```bash
hermes-relay devices # list (defaults to the one stored relay)
+4 -4
View File
@@ -1,6 +1,6 @@
# Troubleshooting <ExperimentalBadge />
Common errors in the desktop CLI, indexed by exact message. If your problem isn't here, [open an issue](https://github.com/Codename-11/hermes-relay/issues/new).
Common errors in the CLI, indexed by exact message. If your problem isn't here, [open an issue](https://github.com/Codename-11/hermes-relay/issues/new).
## `hermes-relay: command not found` / `is not recognized`
@@ -50,7 +50,7 @@ rm ~/.hermes/remote-sessions.json # or delete just this URL's entry
hermes-relay pair --remote ws://<host>:8767
```
Mint a fresh code on the server first: `hermes-pair --ttl 600`.
Mint a fresh code on the server first: `hermes pair --ttl 600`.
## `disconnected before auth`
@@ -137,7 +137,7 @@ Fixed in alpha.12. Pre-alpha.12 `install.sh` and `install.ps1` printed lines lik
## `timed out after 30ms` (or any millisecond-range timeout on a desktop tool)
You're running a pre-fix desktop CLI build. The Python side sends `timeout` in seconds; early Node builds treated it as milliseconds — `30` seconds became 30 ms, and every shell command SIGKILL'd instantly.
You're running a pre-fix CLI build. The Python side sends `timeout` in seconds; early Node builds treated it as milliseconds — `30` seconds became 30 ms, and every shell command SIGKILL'd instantly.
Fixed in releases after 2026-04-23. Upgrade:
@@ -166,7 +166,7 @@ Two layers — check them in order:
curl -s "http://127.0.0.1:8767/desktop/_ping?tool=desktop_terminal"
```
**If `connected: false`**: no desktop CLI is attached. Run `hermes-relay` (bare = shell/TUI mode by default) or `hermes-relay chat`; make sure you didn't pass `--no-tools`; make sure you consented on the first-run prompt.
**If `connected: false`**: no CLI client is attached. Run `hermes-relay` (bare = shell/TUI mode by default) or `hermes-relay chat`; make sure you didn't pass `--no-tools`; make sure you consented on the first-run prompt.
**If `connected: true`** but Hermes still can't see the tools: the plugin isn't loaded by the gateway, or the `desktop` toolset isn't enabled for your session.
+6 -4
View File
@@ -6,8 +6,10 @@ A **connection** in Hermes-Relay is a saved link to a Hermes server. Add multipl
Each connection stores everything needed to talk to one Hermes install:
- API server URL (`http(s)://host:8642`) and auto-derived relay URL (`ws(s)://host:8767`), unless you set a manual relay override
- Its own pairing record — session token, device ID, optional API key
- API server URL (`http(s)://host:8642`) and API key for Chat/session API calls
- Auto-derived dashboard URL (`http(s)://host:9119`) and dashboard cookies for Manage
- Auto-derived relay URL (`ws(s)://host:8767`), unless you set a manual relay override
- Its own Relay pairing record — session token and device ID for Terminal, Bridge, and relay-only power tools
- Its own sessions, memory, personalities, and skill list (fetched from that server)
- Last-active session ID and explicit profile pick, so switching back takes you where you left off
@@ -36,7 +38,7 @@ Open **Settings → Connections**. Each card shows the connection's label, hostn
- **Revoke** — server-side logout. The token is invalidated on the server; the connection stays in the app but is marked unpaired.
- **Remove** — deletes the connection and its stored auth material. The TOFU cert pin for the server's host survives, so if you re-add the same server later, it's still trusted without a re-verify.
Tap **Add connection** to create a new one. This launches the standard QR pairing flow (same as first-time setup). After pairing, the connection is saved with the server's hostname as its default label.
Tap **Add connection** to create a new one. This launches the same connection wizard used during first-time setup. Choose **Standard Hermes** for the normal API/dashboard path, or scan a QR when your host already printed one. Relay pairing is optional and can be added later from the connection card.
## Live status and diagnostics
@@ -65,7 +67,7 @@ For Tailscale, run this on the host before pairing:
```bash
hermes-relay-tailscale enable
hermes-pair --mode auto --prefer tailscale
hermes pair --mode auto --prefer tailscale
```
The helper publishes relay `:8767` and API `:8642`; both must be reachable for the full app to work away from LAN. The route menu in Settings lets you prefer a route for the current session without changing the stored connection.
+25 -3
View File
@@ -12,7 +12,7 @@ The plugin is a thin observer — it never modifies state, never writes to your
**On your server:**
- hermes-agent with the Dashboard Plugin System (upstream commit `01214a7f` on `axiom`, or any later `main` once [PR #8556](https://github.com/NousResearch/hermes-agent/pull/8556) and its dashboard followups merge). `hermes dashboard start` must already work for you.
- hermes-agent with the Dashboard Plugin System. `hermes dashboard start` must already work for you; the Relay tab uses the dashboard plugin mount and does not depend on the legacy session API branch.
- The canonical Hermes-Relay install — if you ran the one-liner on the [Quick Start](/guide/getting-started), you're done. The installer symlinks `~/.hermes/plugins/hermes-relay` → the plugin subtree and the dashboard scanner picks up `plugin/dashboard/manifest.json` automatically.
- A gateway restart after install: `systemctl --user restart hermes-gateway`.
@@ -26,6 +26,28 @@ Open the hermes-agent dashboard in your browser (default: `http://localhost:<das
The plugin's header shows the relay version, overall health (green / red dot), and an **Auto-refresh** toggle that persists to `localStorage`. Turn auto-refresh off if you're reading a specific activity row and don't want it to scroll out from under you.
## Android Manage Surface
The Android app also uses the Hermes dashboard/admin API as its standard management data plane. The **Manage** tab derives the dashboard URL from the active API server URL by default (`:8642` → `:9119`) and reads Skills, Cron, MCP, MCP catalog, Profiles, Models, Keys, and Config from dashboard endpoints when the server supports them.
What you can do from the phone, per section:
- **Skills** — toggle installed skills, plus full **skills-hub** access: browse the configured hub sources (featured skills shown before you search), search across them, read a skill's `SKILL.md` *before* installing, install/uninstall (these run asynchronously on the server), and update everything hub-installed.
- **Cron** — pause/resume/run/delete jobs and view recent runs.
- **MCP** — enable/disable, test, and remove servers; install catalog entries that don't require inline credentials.
- **Profiles** — create profiles (clone-from-default), activate, edit the role description, set a per-profile model, **edit SOUL.md** in a full-file editor, and delete.
- **Models** — change the main model from the full provider/model catalog, including the server's expensive-model confirmation step. Providers without keys appear greyed with a pointer to Keys.
- **Keys** — view the curated env/key inventory (values redacted), set keys (write-only, masked), reveal one (server rate-limited and audit-logged), or clear them.
- **Config** — read the config schema.
A successful dashboard sign-in here also unlocks **standard voice** for the connection — speech uses the same dashboard session (see [Voice Mode](./voice)).
Dashboard sign-in is the upstream-preferred remote auth path. Android supports the bundled `basic` username/password provider and redirect providers such as `nous` or self-hosted OIDC through the dashboard's `/auth/login?provider=...` flow. Successful sign-in stores dashboard cookies, verifies the flat upstream `/api/auth/me` session response, and probes `/api/auth/ws-ticket`. This matches the Hermes Desktop remote-gateway model: sign in once to the dashboard, then reuse that dashboard session for `/api/ws` with a short-lived ticket.
This is separate from relay pairing and from `API_SERVER_KEY`. A dashboard session does not become an API bearer token; Android Chat still uses the API key fallback until its dashboard JSON-RPC chat adapter is enabled. Relay-only capabilities — Terminal, Bridge, Relay sessions, Media inspector, and profile memory file editing — stay under **Settings → Power tools** and show **Requires pairing** until the phone has a paired relay session. Profile **SOUL.md editing is available without Relay** (Manage → Profiles → Edit SOUL, via the dashboard); memory file editing remains in the paired profile inspector.
Server-side dashboard auth is owned by upstream Hermes. For current provider registration, Nous OAuth, username/password, and remote dashboard guidance, use the Hermes [Web Dashboard docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/web-dashboard).
## The Four Tabs
### Relay Management
@@ -34,14 +56,14 @@ The landing tab. Shows:
- **Relay version + uptime + health** — served by the relay's `/relay/info` endpoint. Green dot = reachable, red = `relay unreachable at 127.0.0.1:8767` (the gateway can't see your relay process; check `systemctl --user status hermes-relay`).
- **Paired devices list** — one row per active session. Columns: device name (from the phone's `PairedDeviceInfo`), token prefix (first 8 chars — full tokens are never sent), created-at, last-seen, expires-at, labeled per-channel grants (chat / bridge / terminal / TUI / voice), transport hint (`wss` / `ws`).
- **Revoke button** per row — live. Click to pop a native browser confirm; on OK the button calls `DELETE /api/plugins/hermes-relay/sessions/{prefix}` which the plugin proxy forwards to the relay, and the list auto-reloads on success. Same effect as revoking from the Android app's Settings → Relay sessions or running `hermes-pair --revoke <prefix>` on the server.
- **Revoke button** per row — live. Click to pop a native browser confirm; on OK the button calls `DELETE /api/plugins/hermes-relay/sessions/{prefix}` which the plugin proxy forwards to the relay, and the list auto-reloads on success. Same effect as revoking from the Android app's Settings → Relay sessions or running `hermes pair --revoke <prefix>` on the server.
- **Pair new device** — button in the card header opens the [PairDialog](#pairing-a-new-device) described below.
<!-- TODO: replace with real screenshot — dashboard Relay Management tab with a paired device row -->
#### Pairing a new device
The **Pair new device** button on the Relay Management tab is an alternative to `/hermes-relay-pair` and the `hermes-pair` CLI — same underlying pairing flow, just driven from a browser on your laptop instead of a chat or shell. Useful when you're already in the dashboard reviewing session state and want to onboard a phone without bouncing out to a terminal.
The **Pair new device** button on the Relay Management tab is an alternative to `/hermes-relay-pair` and the `hermes pair` CLI — same underlying pairing flow, just driven from a browser on your laptop instead of a chat or shell. Useful when you're already in the dashboard reviewing session state and want to onboard a phone without bouncing out to a terminal.
**Click the button to open a PairDialog with:**
+2 -1
View File
@@ -23,11 +23,12 @@ The app uses the Hermes `/api/sessions` REST API:
## Authentication
If the Hermes server is configured with `API_SERVER_KEY`, the app sends:
```
Authorization: Bearer <API_SERVER_KEY>
```
Most local Hermes setups don't require a key. The API key field in Settings is optional.
The API key field in Settings is technically optional because Hermes can run an open local API server. For phone-reachable LAN, VPN, or public deployments, set `API_SERVER_KEY` and enter the same value in Android.
When provided, the key is stored in Android's `EncryptedSharedPreferences` using AES-256-GCM encryption backed by the Android Keystore.
+4 -4
View File
@@ -9,15 +9,15 @@ A **<span class="track-badge track-badge--sideload">Sideload only</span>** badge
| Feature | Description |
|---------|-------------|
| [Direct API Connection](/features/direct-api) | HTTP/SSE streaming to Hermes API Server |
| [Voice Mode](/features/voice) | Real-time voice conversation — sphere listens, agent speaks back via your server's configured TTS/STT providers |
| [Voice Mode](/features/voice) | Real-time voice conversation — you talk, the agent answers aloud via your server's configured TTS/STT providers |
| [Markdown Rendering](/features/markdown) | Full markdown with syntax-highlighted code blocks |
| [Reasoning Display](/features/reasoning) | Collapsible extended-thinking blocks |
| [Connections](/features/connections) | Pair with multiple Hermes servers — one-tap switch from the top-bar chip |
| [Connections](/features/connections) | Save multiple Hermes servers — one-tap switch from the top-bar chip |
| [Profiles](/features/profiles) | Auto-discovered upstream agent directories — overlay model + SOUL on chat turns |
| [Personalities](/features/personalities) | Dynamic from `GET /api/config` — picker, agent name on bubbles |
| [Command Palette](/guide/chat#command-palette) | Searchable command browser — 29 gateway commands, personalities, 90+ skills |
| [Slash Commands](/guide/chat#inline-autocomplete) | Inline autocomplete as you type `/` |
| [QR Code Pairing](/guide/getting-started#qr-code-pairing-recommended) | Scan `hermes-pair` QR to auto-configure connection |
| [Standard Setup](/guide/getting-started#connect-android-to-hermes) | Connect by API URL/key first; scan a QR only when you want Relay pairing |
| [Token Tracking](/features/tokens) | Per-message usage and cost |
| [Tool Progress](/features/tools) | Configurable display — Off, Compact, or Detailed |
@@ -135,7 +135,7 @@ For the full decision guide and install instructions for each, see [Release trac
|---------|--------|
| Push Notifications | Future — Agent-initiated alerts |
| Memory Viewer | Future — View/edit agent memories |
| Cross-device handoff | Future — Hand a task from phone to desktop terminal session |
| Cross-device handoff | Future — Hand a task from your phone to a desktop hand |
<style scoped>
.track-badge {
+55 -13
View File
@@ -1,15 +1,28 @@
# Voice Mode
Real-time voice conversation with your Hermes agent. Tap the mic in chat, speak,
and the agent speaks back through the relay-managed voice output provider while
STT still follows your Hermes server configuration.
and the agent speaks back. **No Relay required**: on a standard connection,
speech runs through your Hermes dashboard's audio routes — the same path the
official Hermes Desktop voice mode uses — with the server's configured STT/TTS
providers.
::: tip Two speech routes, picked automatically
- **Standard** — works on a vanilla Hermes install. The phone talks to your
Hermes dashboard; if the dashboard requires sign-in, signing in once under
**Manage** also unlocks voice for that connection.
- **Relay** — when the optional Relay is paired, voice prefers it: per-profile
voice providers, streaming voice output, and the Realtime Agent engine.
You can pin either route under **Settings → Voice → Stable STT/TTS Route**;
the default *Auto* uses Relay when paired, otherwise Standard.
:::
The stable default engine is **Hermes Chat + Voice Output**: Hermes owns the
chat turn, tools, memory, approvals, and transcript, then the relay renders the
assistant response to speech. An opt-in **Realtime Agent** engine is available
for experimental provider-native speech work. It is visibly badged as
Experimental in Voice Settings and can be switched off without changing the
stable voice behavior.
chat turn, tools, memory, approvals, and transcript, then the active speech
route renders the assistant response to audio. An opt-in **Realtime Agent**
engine is available for experimental provider-native speech work — it requires
a paired Relay, is visibly badged as Experimental in Voice Settings, and can be
switched off without changing the stable voice behavior.
## What It Is
@@ -35,11 +48,17 @@ happened.
**On your server:**
Voice mode uses Hermes `stt:` settings for transcription and the relay-managed
`voice_output:` renderer for normal assistant speech. The legacy Hermes `tts:`
section remains the fallback path. Voice output defaults live in
`~/.hermes-relay/config.yaml` or in a selected profile's experimental
`voice_output:` section; provider secrets stay server-side.
- **Standard route (no Relay):** a current hermes-agent whose dashboard
exposes the audio endpoints, with `stt:` and `tts:` configured in
`~/.hermes/config.yaml`. If the dashboard is auth-gated, sign in once under
**Manage** on the phone. Older builds without dashboard audio routes show
"Not available on this Hermes build" in Voice Settings — update
hermes-agent or pair Relay.
- **Relay route:** Hermes `stt:` settings for transcription and the
relay-managed `voice_output:` renderer for assistant speech, with the
legacy Hermes `tts:` section as the fallback path. Voice output defaults
live in `~/.hermes-relay/config.yaml` or in a selected profile's
experimental `voice_output:` section; provider secrets stay server-side.
Common speech output choices:
@@ -227,6 +246,29 @@ voice-output stream cannot compete with the realtime provider. The final spoken
answer is generated by the realtime provider after Hermes returns a compact
result, so tool output is summarized naturally instead of read aloud.
#### Background tasks
Some requests take a while — research, multi-step work, a long command. Instead
of freezing the conversation until they finish, Realtime Agent **promotes** a
slow run to the background: the agent says a short "I'm on it" and you can keep
talking, ask something else, or just wait. When the task finishes, the agent
speaks the answer. Asking for something explicitly long starts a background task
right away.
You control this under **Voice Settings → Realtime Agent → Background tasks**:
- **Promote long tasks** — turn the behavior on or off. With it off, the agent
waits silently until the task finishes (the old behavior).
- **Spoken handoff** — whether the agent says a short acknowledgement when a task
moves to the background, or just shows it on screen.
- **When the answer is ready** — *Speak* it as soon as you're not mid-sentence,
*Notify* and speak when you re-engage, or *Show only* (no spoken answer).
While a background task is running, a small "working on it" chip stays visible in
the voice screen. You can cancel a background task at any time the same way you
cancel any turn. Hermes still owns the task end-to-end — promotion only changes
*when* the answer is spoken, never who runs the tools.
Provider-native Android paths stream mic PCM to a relay-owned realtime provider
WebSocket session. Android commits the captured utterance, the active provider
owns input transcription and speech generation, and Hermes still owns profile
@@ -332,7 +374,7 @@ script automates a quick end-to-end check of the lab routes.
**"Relay returned 413" on synthesize** — you're trying to synthesize more than 5000 characters at once. This is a safety cap on the relay side to avoid runaway TTS costs. Client-side sentence chunking should normally keep individual requests well under this, so a 413 usually means the agent returned one enormous uninterrupted sentence.
**"That pairing code was already used"** — Relay pairing codes are one-shot. Generate a fresh QR from the dashboard Relay tab or `hermes-pair` and scan again. If you only need chat plus voice, skip the Relay pairing path and save the Hermes API URL/key instead; the app will derive the conventional Relay voice URL and probe `/voice/config`.
**"That pairing code was already used"** — Relay pairing codes are one-shot. Generate a fresh QR from the dashboard Relay tab or `hermes pair` and scan again. If you only need chat plus voice, skip the Relay pairing path and save the Hermes API URL/key instead; the app will derive the conventional Relay voice URL and probe `/voice/config`.
## Privacy Note
+128 -170
View File
@@ -8,11 +8,11 @@ import { withBase } from 'vitepress'
- Android device or emulator (API 26+ / Android 8.0+)
- A running [Hermes Agent](https://hermes-agent.nousresearch.com) instance (v0.8.0+ recommended) with the API server enabled
- Python 3.11+ on the server (for the pairing plugin)
- Python 3.11+ on the server only if you plan to install the optional Relay power-user plugin
## Quick Start
Three commands get you from zero to connected:
The default path is standard Hermes first: connect Android to the Hermes API/dashboard, then add Relay pairing only when you need power-user features.
### 1. Install the Android app
@@ -25,176 +25,134 @@ The two builds use different application IDs, so you can install both side-by-si
Once you've decided: install from the [Play Store listing](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay), or grab the file ending in `-sideload-release.apk` from the newest Android release (`android-v*`; historical Android releases used bare `v*`) on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and follow the [Sideload APK](#sideload-apk) section below for step-by-step install and integrity-verification instructions.
### 2. Install the server plugin
### 2. Prepare Hermes on your computer or server
On the machine running your Hermes agent:
Hermes-Relay for Android uses two upstream Hermes surfaces:
- **API server** on `:8642` for Chat and sessions
- **Dashboard** on `:9119` for Manage sign-in and admin screens
If Hermes is already installed, start at `hermes setup --portal`. If you already have a model/provider configured, you can skip that line.
**macOS / Linux / WSL2 / Termux:**
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
hermes setup --portal
mkdir -p ~/.hermes
API_SERVER_KEY="$(openssl rand -hex 32)"
cat >> ~/.hermes/.env <<EOF
API_SERVER_ENABLED=true
API_SERVER_HOST=0.0.0.0
API_SERVER_PORT=8642
API_SERVER_KEY=$API_SERVER_KEY
EOF
chmod 600 ~/.hermes/.env
echo "Android API URL: http://<this-computer-ip>:8642"
echo "Android API key: $API_SERVER_KEY"
hermes gateway
```
**Windows PowerShell:**
```powershell
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
hermes setup --portal
$HermesDir = Join-Path $HOME ".hermes"
New-Item -ItemType Directory -Force $HermesDir | Out-Null
$ApiKey = ([guid]::NewGuid().ToString("N") + [guid]::NewGuid().ToString("N"))
@"
API_SERVER_ENABLED=true
API_SERVER_HOST=0.0.0.0
API_SERVER_PORT=8642
API_SERVER_KEY=$ApiKey
"@ | Add-Content (Join-Path $HermesDir ".env")
Write-Host "Android API URL: http://<this-computer-ip>:8642"
Write-Host "Android API key: $ApiKey"
hermes gateway
```
Replace `<this-computer-ip>` with the address your phone can reach, such as a LAN IP, Tailscale name, or HTTPS reverse-proxy host. Do not use `127.0.0.1` from Android unless Hermes is running on the phone itself.
For **Manage**, also run the Hermes dashboard on a phone-reachable URL. For a trusted LAN or VPN, the quick path is username/password auth:
```bash
# Run in a second terminal on the Hermes host.
DASHBOARD_SECRET="$(openssl rand -base64 32)"
cat >> ~/.hermes/.env <<EOF
HERMES_DASHBOARD_BASIC_AUTH_USERNAME=admin
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=choose-a-strong-password
HERMES_DASHBOARD_BASIC_AUTH_SECRET=$DASHBOARD_SECRET
EOF
chmod 600 ~/.hermes/.env
hermes dashboard --no-open --host 0.0.0.0 --port 9119
```
On Windows, set the same `HERMES_DASHBOARD_*` values in `$HOME\.hermes\.env`, then run the same `hermes dashboard --no-open --host 0.0.0.0 --port 9119` command in a second PowerShell window.
For a public or hosted dashboard, use upstream Hermes dashboard auth with Nous OAuth/OIDC instead of a simple password.
For the upstream details, see the Hermes [Installation](https://hermes-agent.nousresearch.com/docs/getting-started/installation), [Nous Portal](https://hermes-agent.nousresearch.com/docs/integrations/nous-portal), [API Server](https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server), and [Web Dashboard](https://hermes-agent.nousresearch.com/docs/user-guide/features/web-dashboard) docs.
::: warning Dashboard auth and API bearer auth are different
The API key above is for Android Chat on `:8642`. Dashboard sign-in on `:9119` uses dashboard cookies plus short-lived `/api/ws` tickets. Android supports dashboard username/password and Nous/OIDC sign-in for Manage, but dashboard login does not create an API key.
:::
### 3. Connect Android to Hermes
On first launch:
1. Tap through the standard onboarding pages.
2. On **Connect**, choose **Standard Hermes**.
3. Enter the API URL, for example `http://192.168.1.100:8642`, or tap **Scan for Hermes on LAN**.
4. Optional: scan a generic setup QR that contains an API URL, or JSON with `api_url` and optional `api_key`.
5. Enter the same value you set in `API_SERVER_KEY` for Android Chat fallback when the QR did not include it.
6. Optional: enter a Tailscale API URL such as `https://your-host.ts.net:8642`.
7. Tap **Connect**.
This enables Chat plus Manage surfaces such as Skills, Cron, MCP, Profiles, Models/Config, and Settings. Manage may ask you to sign in to the dashboard; choose **Sign in with Nous Research** when the dashboard advertises the `nous` provider, or use the username/password provider on trusted LAN/VPN deployments. Relay pairing is not required for this standard path.
When both a LAN URL and a Tailscale URL are saved, Android probes the saved routes and uses the highest-priority reachable one. Chat and Manage move together: LAN at home, Tailscale when you leave the local network.
### 4. Optional: add Relay power tools
Skip this unless you want Terminal, Bridge, Relay sessions, channel grants, or relay-backed device-control features.
On the Hermes host:
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
hermes relay start --no-ssl
hermes pair
```
The installer follows Hermes's canonical skill-distribution pattern:
`hermes pair` is provided by the Hermes-Relay plugin through upstream Hermes' plugin CLI support; it is not a built-in Hermes core command. Then scan the QR in Android from **Settings -> Connections -> Pair Relay** or from onboarding's **Scan setup QR** path. If the relay is not running, the plugin can still print an API-only QR, so Chat works and Relay can be paired later.
1. Clones the repo to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`)
2. `pip install -e ~/.hermes/hermes-relay/` into the hermes-agent venv — editable, so `git pull` is all that's needed to update the plugin
3. Adds `~/.hermes/hermes-relay/skills` to `skills.external_dirs` in `~/.hermes/config.yaml` (idempotent YAML edit) so the `hermes-relay-pair` skill is picked up on every hermes-agent load
4. Symlinks `~/.hermes/plugins/hermes-relay` → the clone's `plugin/` subdir
5. Installs a thin `~/.local/bin/hermes-pair` shim that execs `python -m plugin.pair` inside the hermes-agent venv
6. Installs a systemd user unit at `~/.config/systemd/user/hermes-relay.service` (optional — skipped on macOS, WSL-without-systemd, bare chroots)
More detail:
Restart hermes-agent after install.
::: tip What you get
- **Full Hermes-Relay Android app features** — sessions browser, conversation history on app restart, personality picker, command palette, memory management. Just install the plugin and it works.
- **Full `android_*` bridge toolset for sideload phones** (tap, type, read screen, screenshot, open apps, send SMS, call, search contacts, share files/MMS attachments, etc.) — registered by the plugin only when `/bridge/status` reports a sideload Device Control phone
- **`/hermes-relay-pair` slash command** — backed by the `devops/hermes-relay-pair` skill and usable from any Hermes chat surface
- **`hermes-pair` shell shim** — for scripts and power-user flows
- **Voice mode endpoints** on relay HTTP routes (transcribe, synthesize, voice config, streaming voice output, and Realtime Agent), with paired relay-session auth first and Hermes API-key fallback for chat+voice-only installs
No separate skill install, no `qrencode` binary needed.
:::
::: info Updating
Because the installer uses `pip install -e` for the plugin and `external_dirs` for the skill, updates are a single command:
```bash
cd ~/.hermes/hermes-relay && git pull && bash install.sh
systemctl --user restart hermes-gateway hermes-relay
```
`bash install.sh` is idempotent — safe to re-run as often as you like. It re-applies every step against the existing install, picks up any new files, and rebuilds the systemd unit from the latest template.
:::
::: info Uninstalling
A clean uninstaller ships in the same repo:
```bash
bash ~/.hermes/hermes-relay/uninstall.sh
```
Or if you don't have the clone any more:
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/uninstall.sh | bash
```
The uninstaller reverses every install step in the opposite order, is idempotent, and never touches state shared with other Hermes tools (`~/.hermes/.env`, the gateway's `state.db`, the `hermes-agent` venv core). Useful flags:
```bash
bash uninstall.sh --dry-run # preview without changing anything
bash uninstall.sh --keep-clone # leave ~/.hermes/hermes-relay in place
bash uninstall.sh --remove-secret # also wipe the QR signing identity
```
By default the QR signing secret at `~/.hermes/hermes-relay-qr-secret` is preserved, so re-installing keeps the same identity and any phones still holding their session tokens stay valid.
:::
### 3. Pair your phone
You have two equivalent entry points — pick whichever fits where you already are:
**From an active Hermes session** (shortest path if you're already chatting with the agent): type `/hermes-relay-pair` in any chat surface — CLI, Discord, Telegram, anywhere Hermes is listening. The `hermes-relay-pair` skill generates the QR and renders it inline for you. No shell required.
**From a shell** (power-user / scriptable): on the server, run
```bash
hermes-pair
```
The dashed `hermes-pair` is a thin shim that execs `python -m plugin.pair` in the hermes-agent venv. Both routes share the same implementation and produce the same QR + plain-text output.
::: warning `hermes pair` (with a space) is not currently exposed
A top-level `hermes pair` sub-command would be nice, but hermes-agent v0.8.0's top-level argparser doesn't forward to third-party plugins' `register_cli_command()` dict yet. Use `/hermes-relay-pair` or the dashed `hermes-pair` shim in the meantime — both work today and will keep working once the upstream gap is closed.
:::
This prints a QR code **and** the plain-text connection details (server URL, API key). Scan the QR from the app's onboarding screen — or type the values in manually if your terminal can't render QR blocks. The text fallback is always shown, so this works inside Hermes's Rich TUI panel and over SSH with limited charsets.
**One scan configures chat *and* the relay.** If you've already started the Hermes-Relay WSS server on the same host (see [Relay Server](#relay-server-optional) below), `hermes-pair` automatically detects it at `localhost:8767`, mints a fresh 6-char pairing code, pre-registers the code with the relay via its loopback-only `/pairing/register` endpoint, and embeds the relay URL and code in the same QR. The phone scans once and is ready for chat, terminal, and bridge.
If the relay isn't running, `hermes-pair` prints an `[info]` line pointing at `hermes relay start` and renders an API-only QR — chat still works, and you can pair with the relay later once it's up. Voice can use the saved Hermes API key too, so chat+voice does not require a paired Relay session. In manual setup, enter the API URL and API key first; the app derives the Relay URL from the same host on port `8767` and only asks for a manual override if `/voice/config` cannot be reached. Bridge Core, terminal/TUI, media, and sideload Device Control routes still require pairing. Plain-LAN voice testing with an API key requires HTTPS or a local runtime opt-in on the relay host: `hermes relay insecure-api-key on` while testing, then `hermes relay insecure-api-key off`. You can also force API-only mode explicitly:
```bash
hermes-pair --no-relay
```
#### Choosing session lifetime + channel grants
By default the phone prompts you to pick a session TTL when you scan the QR (1 day / 7 days / 30 days / 90 days / 1 year / never expire). You can also **pre-set** the TTL and per-channel grants on the host side so the phone's picker dialog opens with your chosen values already selected:
```bash
# Pair for 7 days
hermes-pair --ttl 7d
# Pair indefinitely, limit terminal to 30 days and bridge to 1 day
hermes-pair --ttl never --grants terminal=30d,bridge=1d
# Short-lived dev session
hermes-pair --ttl 1d
```
Supported duration formats: `1d`, `7d`, `30d`, `90d`, `1y`, `never` (or any `<number><unit>` combo where unit is `s`/`m`/`h`/`d`/`w`/`y`). Grants can be pre-set for `terminal`, `bridge`, `tui`, `voice:config`, `voice:stt`, and `voice:tts` and are automatically clamped to the overall session TTL — a grant cannot outlive its session. If you omit voice grants, new sessions get them by default, and older sessions inherit voice from the `chat` grant.
::: tip Camera unavailable? Use manual pairing
If you can't scan a QR — for example you're SSH'd into the host from the same phone you want to pair, the host has no display attached, or there's no second camera-equipped device handy — Hermes-Relay ships a manual fallback flow. Open the app's **Settings → Connections → [active card] → Advanced → Manual pairing code (fallback)** section to read its locally-generated 6-char code, then on the host run:
```bash
hermes-pair --register-code ABCD12 # default 30d session
hermes-pair --register-code ABCD12 --ttl 7d # composes with --ttl / --grants
```
The command pre-registers your code with the local relay over loopback and prints a confirmation. Tap **Connect** in the same card and you're paired. Same 10-minute single-use expiry as QR codes; same TTL/grant rules — `--ttl` and `--grants` flags compose with `--register-code` exactly the same way they compose with the default QR flow.
:::
The phone's TTL picker dialog always opens on scan, preselected with your chosen values, so you have one final chance to confirm or override before the session is created. The selection you make is persisted as the new default for future pairs.
::: tip Never expire
`Never expire` is always available in the picker regardless of transport. The phone treats your intent as the trust model rather than gating on secure-transport detection — if you explicitly pick it, the session stays active until you revoke it from **Relay sessions**.
:::
#### Transport security — plain connections and pairing consent
The app renders a **Transport Security** badge inside the active connection card's Security section:
- 🔒 **Secure (TLS)** — paired over `wss://` / `https://`
- 🔓 **Plain (on LAN / Tailscale / public URL)** — paired over `ws://` / `http://`; the label reflects the **currently active route** so a Tailscale fallback reads honestly even if you originally paired over LAN
- 🔓 **Plain (no TLS)** — plain transport with no active-route information yet (cold start, or manual URL config before the first probe)
Amber, not red — the trust model on `ws://` is the network perimeter, not TLS. A home/office LAN and a private Tailscale network are both legitimate trust domains for plain transport; the badge is factual, not alarming.
**Three different consent gates** exist for plain transport, each firing at the moment that actually changes the threat model:
1. **Scanning an all-plain QR** (no secure route in the candidate list) — one-time per install. The pairing confirm step renders a checkbox: *"I understand this pairing sends traffic in plain text — visible to anyone on the network."* Tick it once per install; the Pair button activates. Subsequent all-plain pairs don't re-prompt. Mixed QRs (LAN + Tailscale) are ungated — the secure fallback is your safety net.
2. **First toggle of "Allow plain (unencrypted) connections"** in the active card's Advanced section — opens a consent dialog with a reason picker (LAN only / Tailscale or VPN / Local dev only). Reason displays on the badge afterward (though the role-aware label above usually overrides it).
3. **Changing a paired TTL to "Never expire"** on a plain connection — inline warning, no forced confirm. The trust model is already established at pair time.
The app also runs a **Trust On First Use** (TOFU) cert pinning check on `wss://` connections: on the first successful handshake it records the server's certificate fingerprint, and every subsequent connect verifies against it. If the cert changes (because the relay was rebuilt, the Let's Encrypt cert rolled over, or an MITM is happening), the connection fails loudly. Re-pairing via QR is taken as explicit consent to pin a new certificate.
#### Relay sessions management
**Settings → Connections → [active card] → Security → Relay sessions** (or simply **Settings → Relay sessions**) lists every phone currently paired with the relay — device name, transport badge, route list, session expiry, per-channel grant chips (tap the info icon next to *Channel grants* for an explanation of what each channel does), and a **Revoke** button per row. Revoking the current device wipes local state and redirects to the pair flow. Any paired phone can revoke any other; for single-operator setups this is intentional (so you can manage everything from one phone), multi-user deployments will need a role model later.
- [Relay Server](#relay-server-optional) for persistent service setup
- [Remote access](/guide/remote-access) for Tailscale, VPN, and public URL recipes
- [Connections](/features/connections) for multiple servers and route switching
::: tip Multiple Hermes servers
The app supports pairing with more than one Hermes server (home + work, dev + prod, etc.) and switching with a single tap. Once you've paired the first server, open **Settings → Connections** to add a second — it launches the same QR flow. When you have two or more, a **Connection** radio list appears inside the agent sheet (tap the agent name in the Chat top bar), letting you switch without re-pairing. See [Connections](/features/connections) for the full model.
The app can save more than one Hermes server, such as Home and Work. Add or switch servers later in **Settings -> Connections**.
:::
::: warning Security
The QR contains credentials — your API key if one is set, and the relay pairing code if a relay block was embedded. The pairing QR is now also signed with HMAC-SHA256 using a host-local secret (auto-created at `~/.hermes/hermes-relay-qr-secret`, mode 0o600). Don't screenshot or share it. The relay code is one-shot and expires in 10 minutes, but the API key is long-lived.
:::
## Dashboard Login From Android
## Hermes Server Setup
Manage uses the Hermes dashboard/admin server and stores dashboard cookies separately from Relay pairing credentials.
Enable the API server in your Hermes configuration (`~/.hermes/.env`):
- **Dashboard auth disabled/open dashboard:** Manage should work as long as Android can reach the dashboard URL.
- **Basic username/password login enabled:** supported. Android posts to `/auth/password-login` with the upstream `basic` provider, stores the dashboard cookies, and checks `/api/auth/me`.
- **Nous OAuth / OIDC redirect login enabled:** supported for dashboard auth. Android opens the dashboard's `/auth/login?provider=...` flow in an in-app WebView, imports the resulting dashboard cookies, checks `/api/auth/me`, and probes `/api/auth/ws-ticket`.
- **Custom password providers:** supported when `/api/auth/providers` advertises `supports_password: true`.
```bash
API_SERVER_ENABLED=true
API_SERVER_KEY=your-secret-key-here
API_SERVER_HOST=0.0.0.0 # Allow network access (default is localhost only)
API_SERVER_PORT=8642
```
::: tip API key is optional for local setups
If you're running Hermes on the same machine (or connecting via `localhost`), you can leave `API_SERVER_KEY` unset. The key is only needed when exposing the API server over the network. If you do set one, `hermes-pair` reads it automatically, and the dashboard's pair/repair QR flow now reads the same key through the relay so chat sessions and voice pairing stay in sync.
:::
Relay pairing does not replace dashboard login. Dashboard login also does not mint an API key: it matches the Hermes Desktop remote-gateway path by authenticating `/api/ws` and `/api/pty` with dashboard cookies plus a single-use ticket from `/api/auth/ws-ticket`. Android now supports that login and ticket probe; API-key chat remains the fallback until Android's native dashboard-gateway chat adapter is wired in.
## Sideload APK
@@ -224,7 +182,7 @@ The exact wording varies by OEM (Samsung calls it "Install unknown apps", Pixel
### 3. Install it
Open the downloaded APK from your Downloads notification or the Files app, then tap **Install**. The first launch will walk you through onboarding and pairing.
Open the downloaded APK from your Downloads notification or the Files app, then tap **Install**. The first launch will walk you through standard Hermes connection; Relay pairing is optional for power tools.
### 4. Verify integrity (optional but recommended)
@@ -273,22 +231,22 @@ scripts/dev.bat build # Build debug APK
scripts/dev.bat run # Build + install + launch (requires connected device)
```
## Manual Pairing
## Manual Setup
If you don't want to use QR pairing, you can enter connection details by hand — either during the app's onboarding flow or later from Settings.
If you don't want to scan a QR, enter standard connection details by hand during onboarding or later from Settings.
**During onboarding:**
1. The app opens with an onboarding flow
2. On the **Connect** page, tap **Enter manually**
3. Type your API Server URL (e.g., `http://192.168.1.100:8642`) and API Key
4. Tap **Test Connection** to verify
5. Optionally enter a **Relay URL** for Terminal/Bridge features
6. Tap **Get Started**
2. On the **Connect** page, tap **Standard Hermes**
3. Type your API server URL, for example `http://192.168.1.100:8642`, scan for Hermes on LAN, or scan a generic QR containing the API URL/key
4. Enter the same value you set in `API_SERVER_KEY` for Android Chat fallback when it was not included by the QR
5. Optional: enter a Tailscale API URL such as `https://your-host.ts.net:8642`
6. Tap **Connect**
**After onboarding:** open **Settings → Connections**. Each paired server is a card in the list; the currently-active card expands inline to show status rows, endpoint details, and an **Advanced** section with manual URL config, insecure-mode toggle, and the manual pairing-code fallback flow. The per-card **Re-pair** button is the one-tap entry point for scanning a new QR. API Server URL, API Key, Relay URL, and Insecure Mode all live under the active card's **Advanced** expander, with **Save & Test** for each.
**After onboarding:** open **Settings → Connections**. Each Hermes host is a card in the list; the currently-active card expands inline to show status rows, route details, and an **Advanced** section with manual API URL/API key config, Relay URL override, insecure-mode toggle, and the manual Relay pairing-code fallback flow. The per-card **Pair Relay** / **Re-pair** button is the entry point for scanning a Relay QR when you need power tools.
The `hermes-pair` command always prints these same values as plain text alongside the QR code, so you can copy them directly.
For Standard setup there is no built-in upstream mobile pairing command yet, so use LAN scan, copy/paste, or a generic QR containing the API URL/key. If a QR includes a Relay block from the Hermes-Relay plugin, Android will show the Relay pairing confirmation and TTL/grants picker; if it is API-only, Android saves the standard API/dashboard connection.
## Relay Server (Optional)
@@ -302,18 +260,18 @@ hermes relay start --no-ssl
# Or directly from a repo checkout:
python -m plugin.relay --no-ssl
```
Run this on the same machine as hermes-agent. If the relay is running when you execute `hermes-pair` (or `/hermes-relay-pair`), its URL and a freshly-registered pairing code are automatically embedded in the QR — you don't need to enter anything in the app.
Run this on the same machine as hermes-agent. On current upstream Hermes installs with the Hermes-Relay plugin enabled, the plugin-provided `hermes pair` command is available. If the relay is running when you execute `hermes pair` (or `/hermes-relay-pair`), its URL and a freshly-registered pairing code are automatically embedded in the QR — you don't need to enter anything in the app.
:::
For persistent deployment, Docker, systemd, and TLS options, see the [Relay Server docs](/reference/relay-server).
If you only saw an API-only QR earlier (because the relay wasn't running), just start the relay and re-run `hermes-pair` — the new QR will include the relay block.
If you only saw an API-only QR earlier (because the relay wasn't running), just start the relay and re-run the plugin-provided `hermes pair` — the new QR will include the relay block.
## Connecting from Anywhere (Tailscale, VPN, Public URL)
Hermes-Relay supports **multi-endpoint pairing**: one QR carries every network path your server is reachable on, and the phone auto-picks whichever is reachable at the moment. Works across LAN / cell / tailnet / public reverse proxy without re-pairing when you change networks.
**Default — `--mode auto`.** `hermes-pair --mode auto` (run on the server) probes the LAN, detects Tailscale if it's running, and emits an ordered candidate list in the QR. To include an external reverse-proxy or Cloudflare Tunnel URL, add `--public-url https://hermes.example.com`.
**Default — `--mode auto`.** `hermes pair --mode auto` (run on the server) probes the LAN, detects Tailscale if it's running, and emits an ordered candidate list in the QR. To include an external reverse-proxy or Cloudflare Tunnel URL, add `--public-url https://hermes.example.com`.
**Enable Tailscale on the server** with `hermes-relay-tailscale enable` — this fronts the loopback-bound relay port `8767` and Hermes API port `8642` with `tailscale serve`, using Tailscale's managed TLS + tailnet ACLs. Both ports matter: relay pairing covers terminal/bridge/control features, while chat and API-key voice use the Hermes API server. Skip this if you prefer a reverse proxy + Let's Encrypt, or a self-hosted VPN — both work identically as long as the phone can reach both services.
+12 -11
View File
@@ -1,22 +1,23 @@
# Hermes-Relay — Android
This section covers the **Android client** for [Hermes Agent](https://hermes-agent.nousresearch.com): chat, voice, relay pairing, terminal/TUI relay, notifications, and optional sideload Device Control.
This section covers the **Android client** for [Hermes Agent](https://hermes-agent.nousresearch.com): standard API/dashboard setup, chat, Manage, voice, optional Relay pairing, terminal/TUI relay, notifications, and optional sideload Device Control.
::: tip Looking for the desktop CLI?
The desktop terminal client (Windows / macOS / Linux) lives in its own section: **[Desktop CLI →](/desktop/)**. Both clients pair against the same Hermes-Relay server and share `~/.hermes/remote-sessions.json` — pair once from either, both work.
::: tip Want the agent to have hands on your other machines too?
The Hermes-Relay CLI (Windows today; macOS / Linux coming soon) gives your Hermes agent consent-gated filesystem, terminal, and screenshot access on any machine you pair — plus a terminal escape hatch for you: **[CLI →](/desktop/)**. Both surfaces share the same relay pairing and `~/.hermes/remote-sessions.json`.
:::
Hermes-Relay is a native Android app for [Hermes Agent](https://hermes-agent.nousresearch.com). Chat with your agent, manage sessions, use voice, pair to the relay, and access remote terminal/TUI surfaces. The Google Play build ships Bridge Core only; sideload builds add AccessibilityService-backed Device Control.
Hermes-Relay is a native Android app for [Hermes Agent](https://hermes-agent.nousresearch.com). Chat with your agent through the Hermes API server, manage Skills/Cron/MCP/Profile surfaces through the dashboard, use voice, and optionally pair Relay for terminal/TUI and bridge power tools. The Google Play build ships Bridge Core only; sideload builds add AccessibilityService-backed Device Control.
## Quick Install
## Quick Start
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
```
1. Install Hermes and run the API server/dashboard on your host.
2. Install the Android app.
3. Choose **Standard Hermes** and enter the API URL/key.
4. Add Relay pairing later only if you want Terminal, Bridge, Relay sessions, or device-control power tools.
This installs the server-side plugin. One command, full features — sessions browser, conversation history, personality picker, command palette, memory management, relay WSS for terminal/TUI and Bridge Core, sideload Device Control routes, and relay HTTP voice routes all work out of the box on any standard `hermes-agent` install. Grab the Android app from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases), then either type `/hermes-relay-pair` in any Hermes chat surface or run `hermes-pair` from a shell to generate a pairing QR. See [Installation & Setup](/guide/getting-started) for the full walkthrough.
See [Installation & Setup](/guide/getting-started) for copy/paste host commands and upstream Hermes links.
To uninstall later:
If you installed the optional Relay plugin and want to uninstall it later:
```bash
bash ~/.hermes/hermes-relay/uninstall.sh
@@ -39,7 +40,7 @@ Phone (WSS/HTTP) → Relay Server (:8767) [Bridge Core, terminal, TUI, me
Chat connects directly to the Hermes API Server using the Sessions API with SSE streaming. The relay server handles Bridge Core, terminal, TUI, media, notification companion, sessions, and `/voice/*` routes. Sideload builds additionally expose Android Device Control routes.
## Current Status — v0.8.0
## Current Status — v0.8.1
| Feature | Status |
|---------|--------|
+48
View File
@@ -0,0 +1,48 @@
# Quick Start
Install → connect → talk, in about two minutes. This page is deliberately
short: every step links to a detail page if you want the full story, and
nothing here requires the Relay plugin — a vanilla Hermes install is enough.
## 1. Install the app
Grab the latest APK from the
[releases page](https://github.com/Codename-11/hermes-relay/releases) and
install it. Play-store, integrity-verification, and build-from-source options
live in [Installation & Setup](./getting-started).
## 2. Have Hermes running
You need a reachable [hermes-agent](https://github.com/NousResearch/hermes-agent)
with its API server enabled. If yours isn't running yet,
[Installation & Setup](./getting-started) has the copy/paste commands for the
host machine.
## 3. Connect
Open the app and swipe through to **Connect**. Enter your server's address —
`http://<host>:8642` — and the wizard probes everything for you, finishing
with a capability card:
| Line | What it means |
|---|---|
| **Chat** | API server reachable — you can talk |
| **Manage** | Dashboard found — skills, models, keys, profiles from the phone |
| **Voice** | Speech ready via your server (or one sign-in away) |
| **Relay** | Optional power tools — fine to leave unpaired |
## 4. Sign in to Manage (only if asked)
If your dashboard requires sign-in, do it once under the **Manage** tab.
That same session also unlocks voice for the connection.
## 5. Talk
Type a message, or tap the mic and speak. That's the whole standard setup.
::: details Want more? Power tools via Relay
Pairing the optional [Hermes-Relay plugin](./getting-started#relay-server-optional)
adds Terminal (a real tmux on your server), Bridge device control (sideload
builds), realtime provider-native voice, and per-profile voice providers.
Everything above keeps working without it.
:::
+59 -6
View File
@@ -4,12 +4,16 @@ Hermes-Relay can keep one paired phone connected as it moves between LAN, Tailsc
## What Uses Which Connection
One pairing QR can configure both parts of the app:
Standard setup saves the API server URL and API key directly. The setup form
also has a **Remote access — Tailscale URL** field; fill it in and Android
stores both routes and uses the highest-priority reachable one. A Relay
pairing QR can also carry both parts of the app when you enable the optional
relay:
- **Chat and API-backed voice** use the Hermes API server URL and the Hermes API bearer key when one is configured.
- **Terminal, bridge, TUI, media/session management, clipboard, profile writes, Android control, and relay-token voice fallback** use the relay URL and require a paired relay session token.
The app stores your base API URL and relay URL on the connection, then uses the active route selected from the QR's endpoint list at runtime. That means a single scan can stay valid when LAN is reachable at home and Tailscale is the reachable route away from home.
The app stores your base API URL, optional Tailscale API URL, and relay URL on the connection, then uses the active route selected from saved route candidates at runtime. That means one saved connection can use LAN at home and Tailscale away from home.
## Recommended: Tailscale
@@ -17,7 +21,7 @@ On the Hermes host:
```bash
hermes-relay-tailscale enable
hermes-pair --mode auto --prefer tailscale
hermes pair --mode auto --prefer tailscale
```
The Tailscale helper publishes both required loopback services:
@@ -40,23 +44,72 @@ hermes-relay-tailscale status
Use `--mode auto` for the normal multi-endpoint QR:
```bash
hermes-pair --mode auto
hermes pair --mode auto
```
It emits LAN when available, adds Tailscale when the helper detects a tailnet hostname, and adds a public route when you pass `--public-url`:
```bash
hermes-pair --mode auto --public-url https://hermes.example.com/relay
hermes pair --mode auto --public-url https://hermes.example.com/relay
```
Use `--prefer tailscale` when you want the phone to try Tailscale first but still keep LAN as a fallback:
```bash
hermes-pair --mode auto --prefer tailscale
hermes pair --mode auto --prefer tailscale
```
You can also override from the phone: **Settings -> Connections -> active connection -> Routes -> Prefer this route**.
## Which URL Do I Enter?
Route fields want the **API server** (port `8642` by default) — never the
dashboard (`9119`) or relay (`8767`); those are derived from the host
automatically. You can type just a host or IP: `100.71.8.56` is saved as
`http://100.71.8.56:8642`, and the editor previews the exact URL before you
save.
Pick the scheme by how the server is reached:
- **Raw Tailscale IP (`100.x.y.z`) or LAN IP** → `http://` (the default).
The Hermes API server speaks plain HTTP; an `https://` route against it
fails its TLS handshake on every probe and never wins. This also requires
the API server to listen beyond loopback (`0.0.0.0:8642` or the tailnet
interface).
- **`*.ts.net` hostname fronted by `hermes-relay-tailscale enable`** →
`https://` — Tailscale terminates TLS for the MagicDNS hostname (the cert
is only valid for that name, not for the raw `100.x` IP).
- **Public reverse proxy** → `https://` with whatever host/port the proxy
exposes.
After saving, the Routes card probes immediately and each row shows its
verdict — "Reachable", or "Unreachable" with the reason (TLS failure,
connection refused, timeout, HTTP status). A route that never shows
"Reachable" is misconfigured, not just unlucky.
## Add or Edit Routes on the Phone
You don't need to re-run setup (or use a QR) to add remote access later.
Open **Settings -> Connections -> active connection -> Show routes**:
- **Add route** opens an editor with Tailscale / Public / Custom presets and
an API URL field. The relay and dashboard URLs are derived from the host
automatically.
- Each fallback route's menu has **Edit route** and **Remove route**. The
primary route mirrors the connection's API URL and is edited there instead.
- When the phone is on Tailscale but the connection has no Tailscale route,
the Connections card shows an **Add Tailscale route** shortcut.
Saved routes take effect immediately — the app re-probes and switches without
a reconnect. The setup result card and the status pill both call out when a
connection is LAN-only so you know remote access isn't configured yet.
::: tip One sign-in per route
Dashboard sessions are per-host. The first time Manage or voice runs over a
new route (for example the Tailscale URL), sign in to Manage once on that
route; the app keeps both sessions afterwards.
:::
## Other Remote Paths
Reverse proxies work if they expose both services:
+21
View File
@@ -9,6 +9,27 @@
- Check firewall rules on the server
- Try the URL in a browser: `http://your-server:8642/health`
## Android Studio can't see a phone over Tailscale ADB
Use Android's **Wireless debugging** flow, but route it through the phone's
Tailscale IP (`100.x.y.z`) instead of the LAN IP. The pairing-code dialog and
the main Wireless debugging screen usually show different ports:
```bash
adb pair 100.x.y.z:<pairing-port> <pairing-code>
adb connect 100.x.y.z:<wireless-debugging-port>
adb devices -l
```
Use the port from **Pair device with pairing code** only for `adb pair`. For
`adb connect`, use the **IP address & port** shown on the main Wireless
debugging screen. Once `adb devices -l` lists the phone, Android Studio uses
that same ADB transport for Run, Logcat, and Device Explorer.
If `adb connect` is refused, the pairing succeeded but the wrong port was used,
or Wireless debugging rotated ports. Reopen **Developer options -> Wireless
debugging** on the phone and copy the current main port.
## Messages not streaming
- Check your API key is correct
+35 -103
View File
@@ -3,136 +3,68 @@ layout: home
hero:
name: Hermes-Relay
text: One Hermes agent. Two ways to use it.
tagline: An Android companion for self-hosted Hermes chat, voice, relay pairing, and optional sideload phone control, plus a desktop CLI that lets you use a server-deployed Hermes from your laptop as if it were running locally.
text: Runs on your machine. Lives on your devices.
tagline: Pair your Hermes agent with the devices around you — a native Android companion for chat, voice, and full phone control, plus a single-binary CLI that gives the agent hands on any machine you put it on.
actions:
- theme: brand
text: Use Hermes from my desktop
link: /desktop/
- theme: alt
text: Use Hermes on Android
text: Get the Android app
link: /guide/getting-started
- theme: alt
text: Give it hands — CLI
link: /desktop/
features:
- icon:
src: /icons/chat.svg
width: 40
height: 40
title: Two surfaces, one pair
details: Pair once. Both the Android app and the desktop CLI share `~/.hermes/remote-sessions.json` and the same WSS relay — sign in on either, both light up.
- icon:
src: /icons/sessions.svg
width: 40
height: 40
title: Server-deployed Hermes, native feel
details: The agent brain (LLM, tools, memory, sessions) lives on your Hermes host. The desktop CLI gives you the same shell, the same TUI, the same `/paste` flow — over WSS, no Python on your laptop.
- icon:
src: /icons/reasoning.svg
width: 40
height: 40
title: Native paste in your shell
details: "`Win+Shift+S` → `Ctrl+A v` inside `hermes-relay shell` ships the screenshot to the server inbox and types `/paste` into the TUI. Identical UX to a local Hermes install."
title: Chat that streams, not spins
details: Direct HTTP/SSE to your Hermes API server — markdown, syntax-highlighted code, reasoning blocks, live tool-progress cards. No cloud relay in the path.
- icon:
src: /icons/personalities.svg
width: 40
height: 40
title: Android Bridge Core
details: Google Play keeps chat, voice, terminal/TUI relay, notifications, media, and sessions. Sideload builds add AccessibilityService-backed phone control for screen reading, taps, typing, screenshots, and safety rails.
title: Hands-free voice
details: Talk to your agent using your server's own TTS/STT — or opt into the provider-native Realtime Agent for low-latency, barge-in conversation.
- icon:
src: /icons/tokens.svg
src: /icons/sessions.svg
width: 40
height: 40
title: Local tool routing (Desktop)
details: The remote agent can read, write, search, run shell commands, capture screenshots, and read your clipboard — on your machine — through the same relay it uses for chat. Consent-gated.
title: Manage from your pocket
details: Skills, cron jobs, profiles, models, and keys — your server's dashboard controls, rebuilt native. One sign-in unlocks Manage and voice.
- icon:
src: /icons/tools.svg
width: 40
height: 40
title: Multi-endpoint pairing
details: One QR carries LAN + Tailscale + public URLs. The client races them in priority order on every connect and on network change, so the same pair just works whether you're at home, on the train, or behind your VPN.
title: Your phone, on the agent's toolbelt
details: Sideload builds add screen reading, taps, typing, and vision-driven navigation — fenced by a per-app blocklist, destructive-verb confirmation, and auto-disable.
- icon:
src: /icons/tokens.svg
width: 40
height: 40
title: Hands on any machine
details: Install the single-binary CLI — desktop, laptop, or headless box — and the agent can read, write, search, run commands, and capture screens there. Consent-gated per device.
- icon:
src: /icons/markdown.svg
width: 40
height: 40
title: Streaming chat + structured tools
details: Direct SSE to the Hermes API server with markdown rendering, syntax-highlighted code, reasoning blocks, tool-progress cards, and a 29-command searchable palette.
title: Works at home and away
details: Connections carry LAN, Tailscale, and public routes, and the app picks the best one on every connect and network change — home, train, or VPN, it just works.
- icon:
src: /icons/reasoning.svg
width: 40
height: 40
title: Notifications and media in the loop
details: Forward phone notifications to your agent, and let it hand files, images, and rich cards back into chat — shareable through native Android flows.
- icon:
src: /icons/security.svg
width: 40
height: 40
title: Secure by default
details: QR pairing, session tokens (Android Keystore / mode-0600 file), TOFU cert pinning, per-channel grants, user-chosen TTLs, revocable from any client.
title: Private by architecture
details: No cloud in the path — QR pairing, Keystore-held tokens, TOFU cert pinning, per-channel grants with TTLs you choose, revocable from any client.
---
<style>
:root {
--hr-card-bg: var(--vp-c-bg-soft);
--hr-card-border: var(--vp-c-divider);
}
.dark {
--hr-card-bg: var(--vp-c-bg-alt);
}
.surface-grid {
display: grid;
gap: 1.25rem;
grid-template-columns: 1fr;
margin: 2rem auto 0;
max-width: 1152px;
padding: 0 1.5rem;
}
@media (min-width: 768px) {
.surface-grid { grid-template-columns: 1fr 1fr; }
}
.surface-card {
background: var(--hr-card-bg);
border: 1px solid var(--hr-card-border);
border-radius: 12px;
padding: 1.5rem;
}
.surface-card h3 {
margin: 0 0 .25rem;
font-size: 1.25rem;
}
.surface-card .surface-tag {
font-size: .8rem;
font-weight: 500;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--vp-c-text-2);
}
.surface-card p { margin: .75rem 0; line-height: 1.55; }
.surface-card ul { padding-left: 1.1rem; margin: .5rem 0 1rem; }
.surface-card li { margin: .25rem 0; }
.surface-card .cta {
display: inline-block;
font-weight: 500;
margin-top: .5rem;
}
</style>
<div class="surface-grid">
<div class="surface-card">
<div class="surface-tag">Surface 1</div>
<h3>Android — Bridge Core</h3>
<p>Native app. Talks directly to the Hermes API server for chat, plus a WSS relay for voice, terminal/TUI relay, media, sessions, status, and notification companion.</p>
<ul>
<li>Streaming chat, multi-Connection, agent profiles, personalities</li>
<li>Voice mode with the morphing sphere + your server's TTS/STT</li>
<li>Google Play track: no AccessibilityService, screen reading, taps, typing, screenshots, SMS, calls, contacts, location, or unattended control</li>
<li>Sideload track: AccessibilityService-backed Device Control with per-app blocklist, destructive-verb confirmation, and auto-disable</li>
</ul>
<a class="cta" href="./guide/getting-started.html">Install the Android app →</a>
</div>
<div class="surface-card">
<div class="surface-tag">Surface 2 · Experimental</div>
<h3>Desktop CLI — terminal</h3>
<p>One binary, no Node required. Pipes the full Hermes TUI over a PTY, or streams structured chat events for scripting. The remote agent can also reach back through the relay and run tools on <em>your</em> machine.</p>
<ul>
<li><code>shell</code> mode — full Hermes CLI verbatim, in tmux on the server</li>
<li><code>chat</code> mode — REPL or one-shot, JSON event stream for scripting</li>
<li>In-shell <code>Ctrl+A v</code> paste · multi-monitor screenshots · editor tool</li>
<li>Daemon mode keeps tools advertised even when no shell is open</li>
</ul>
<a class="cta" href="./desktop/">Use Hermes from my desktop →</a>
</div>
</div>
<!-- Home body intentionally empty — the sphere, How-it-works strip, surface
cards, and Get-started section are slotted via .vitepress/theme/index.ts
(markdown body always renders below the VPFeatures grid, which is the
wrong place for all of them). -->
+4 -2
View File
@@ -8,7 +8,7 @@ description: Privacy policy for the Hermes-Relay Android app
**Hermes-Relay** · Effective date: May 19, 2026
Hermes-Relay is a native Android app that connects to your self-hosted [Hermes Agent](https://github.com/NousResearch/hermes-agent) server. This policy describes how the app handles your data.
Hermes-Relay is a native Android app that connects to your own [Hermes Agent](https://github.com/NousResearch/hermes-agent) host. This policy describes how the app handles your data.
## Summary
@@ -70,7 +70,9 @@ Your Hermes server may connect to AI providers such as OpenAI or Anthropic serve
From the app's Settings screen, you can:
- **Export** your configuration (server URLs and preferences; secrets excluded)
- **Export** a full connection backup. The file includes server URLs, preferences,
API keys, relay session tokens, device IDs, and dashboard cookies so restored
connections can work without manual re-entry. Keep it private.
- **Import** a saved configuration
- **Full reset** to permanently delete local data including encrypted credentials
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.
+20 -5
View File
@@ -1,6 +1,6 @@
# Hermes API Reference
Hermes-Relay communicates with the Hermes API Server using the following endpoints. If the server is configured with `API_SERVER_KEY`, requests must include a Bearer token in the `Authorization` header. Most local setups don't require a key.
Hermes-Relay communicates with the Hermes API Server using the following endpoints. If the server is configured with `API_SERVER_KEY`, requests must include a Bearer token in the `Authorization` header. For phone-reachable LAN, VPN, or public setups, configure `API_SERVER_KEY` and enter the same value in Android.
## Base URL
@@ -18,9 +18,9 @@ Hermes-Relay voice endpoints can reuse this same Bearer token. Relay validates i
## How endpoints get served
Installing the plugin via `install.sh` is enough to make all of the endpoints below work — including the management ones (`/api/sessions/*`, `/api/memory`, `/api/skills`, `/api/config`, `/api/available-models`). The plugin wires the gateway up at install time so these are served on the same `:8642` host as the standard `/v1/*` endpoints, with the same `Authorization: Bearer …` auth.
Current upstream Hermes serves the session API natively on `/api/sessions/*`, advertises it through `/v1/capabilities`, and exposes read-only skill/toolset discovery through `/v1/skills` and `/v1/toolsets`. Installing Hermes-Relay via `install.sh` still adds a bootstrap compatibility hook for older hermes-agent builds and for remaining management surfaces that are not current API-server routes (`/api/memory`, legacy `/api/skills`, `/api/config`, `/api/available-models`).
**Chat streaming uses standard `/v1/runs`** by default — it emits structured `tool.started`/`tool.completed` SSE events for live tool progress cards in the Android app. The app's `Settings → Chat → Streaming endpoint = "Auto"` (default) probes per-endpoint capability and picks the best chat path automatically; you can manually force `Sessions` or `Runs` mode for debugging.
**Chat streaming uses `Auto` by default.** The app probes `/v1/capabilities` first, then legacy route probes, and prefers native `/api/sessions/{id}/chat/stream` when available. Older builds fall back to `/v1/chat/completions` or `/v1/runs`; you can manually force `Sessions`, `Completions`, or `Runs` mode for debugging.
## Endpoints
@@ -36,6 +36,17 @@ Returns server health status. Used by the app to verify connectivity (green/red
---
### Capabilities
```
GET /v1/capabilities
```
Returns the API-server feature and endpoint map. The app uses it before falling
back to legacy route probes.
---
### List Sessions
```
@@ -47,7 +58,8 @@ Returns all chat sessions.
**Response:**
```json
{
"sessions": [
"object": "list",
"data": [
{
"id": "uuid",
"title": "Session title",
@@ -59,6 +71,8 @@ Returns all chat sessions.
}
```
Older compatibility builds may return `items` or `sessions`; the app accepts all three shapes.
---
### Create Session
@@ -91,7 +105,8 @@ Returns message history for a session.
**Response:**
```json
{
"messages": [
"object": "list",
"data": [
{
"id": "uuid",
"role": "user | assistant",
+27 -11
View File
@@ -4,7 +4,17 @@ Hermes-Relay stores its settings using Android's DataStore, Android Keystore (fo
## Connection Settings
These are configured during onboarding or from the **Settings → Connections** screen. That screen is the single authoritative home for everything connection-related (as of the 2026-04-21 unification — the legacy singular *Settings → Connection* subpage was folded in here). Each paired server appears as its own card in the list; the currently-active card expands inline to surface all deep-configuration UI.
These are configured during onboarding or from the **Settings → Connections** screen. That screen is the single authoritative home for everything connection-related (as of the 2026-04-21 unification — the legacy singular *Settings → Connection* subpage was folded in here). Each saved Hermes server appears as its own card in the list; the currently-active card expands inline to surface all deep-configuration UI.
Hermes-Relay now treats connection auth as three related but separate contexts:
- **Dashboard sign-in** (`:9119`) — upstream-preferred remote identity for the standard dashboard/desktop path. Manage uses dashboard cookies; dashboard-gateway chat uses short-lived `/api/ws` tickets minted from those cookies. Android supports username/password and redirect providers such as Nous/OIDC for this surface.
- **API connection** (`:8642`) — OpenAI-compatible chat, sessions, and portable API calls. If the Hermes API server is configured with `API_SERVER_KEY`, Android stores that bearer key and uses it for Chat while dashboard-gateway JSON-RPC chat is being wired in.
- **Pairing** (`:8767`) — relay grants for Terminal, Bridge, relay sessions, media relay inspection, and profile memory file editing. Pairing is not required for standard dashboard/API use, but it is required for relay power tools.
The default bottom navigation is **Chat**, **Manage**, and **Settings**. Terminal and Bridge are still available from **Settings → Power tools** and deep links, but unpaired devices see a clear **Requires pairing** / **Pair to unlock** gate before those relay-only screens load.
The **Manage** tab uses the dashboard session, not relay pairing. It covers Skills, Cron jobs, MCP servers, the MCP catalog, Profiles, Models, and Config. Actions that write dashboard state use upstream dashboard endpoints; profile SOUL is view-only here, while SOUL/memory file editing remains a paired power tool.
**On any card (active or not) — the per-connection action row:**
- **Reconnect** (only on Stale state)
@@ -13,18 +23,20 @@ These are configured during onboarding or from the **Settings → Connections**
**On the active card — a deep body below the action row.** Each section is headed by a labelMedium header and a one-line caption so the card self-narrates:
- **Connection health** (always visible) — *"Tap any row for details."* Three tappable rows (API / Relay / Session) open detail sheets with token, endpoint, health, and session info.
- **Routes (N)** (when the pairing carries multi-endpoint candidates) — *"The app picks the fastest reachable network automatically and switches when you change networks."* Expander reveals one row per route with role chip (LAN / Tailscale / Public / Custom VPN), per-row Secure/Plain security chip, state chip (Active / Fallback), probe-now button, per-candidate *Prefer this route* override, and per-candidate TOFU pin inspection.
- **Advanced** (collapsible) — *"Manual setup — most people don't need this after QR pairing."* Holds:
- **Manual URL config** — API Server URL, API Key, Relay URL, each with **Save & Test**.
- **Routes (N)** (when Standard setup saved a LAN/Tailscale URL or pairing carried multi-endpoint candidates) — *"The app picks the fastest reachable network automatically and switches when you change networks."* Expander reveals one row per route with role chip (LAN / Tailscale / Public / Custom VPN), per-row Secure/Plain security chip, state chip (Active / Fallback), probe-now button, per-candidate *Prefer this route* override, and per-candidate TOFU pin inspection.
- **Advanced** (collapsible) — *"Manual setup — most people don't need this after Standard Hermes setup."* Holds:
- **Manual URL config** — API Server URL, API Key, Relay URL, each with **Save & Test**. This preserves the direct host/API-key path for custom networks, proxies, and compatibility installs.
- **Allow plain (unencrypted) connections** toggle — first enable opens a consent dialog with a reason picker (LAN only / Tailscale or VPN / Local dev only). Reason is stored for later but the Transport Security badge usually derives a more accurate label from the live active-route role. Operator intent is the trust model — the toggle gates the UI's ability to save `ws://` / `http://` URLs, nothing server-side.
- **Disconnect** button — drops the active WSS without clearing the session token.
- **Manual pairing code (fallback)** — the 3-step flow for when you can't use QR scanning. (1) Copy the locally-generated 6-char code; (2) on the host, run `hermes-pair --register-code <code>`; (3) tap **Connect** here. Canonical flow is still the QR from `/hermes-relay-pair` — use this only when QR scanning is physically impossible. Bridge control is gated by the master toggle on the Bridge tab, NOT by this code.
- **Manual pairing code (fallback)** — the 3-step flow for when you can't use QR scanning. (1) Copy the locally-generated 6-char code; (2) on the host, run `hermes pair --register-code <code>`; (3) tap **Connect** here. Canonical flow is still the QR from `/hermes-relay-pair` or `hermes pair` — use this only when QR scanning is physically impossible. Bridge control is gated by the master toggle on the Bridge tab, NOT by this code.
- **Security** (always visible) — Transport Security badge (🔒 secure / 🔓 plain with reason / 🔓 unknown), Tailscale-detected chip, Hardware-keystore badge, and a **Relay sessions** row that navigates to the full list of phones paired with this server.
| Setting | Storage | Description |
|---------|---------|-------------|
| API Server URL | EncryptedSharedPreferences | Base URL of the Hermes API Server (e.g., `http://192.168.1.100:8642`) |
| API Key (optional) | EncryptedSharedPreferences | Bearer token for API authentication — only needed if server has `API_SERVER_KEY` set |
| API Key | EncryptedSharedPreferences | Bearer token for API-server authentication. Used by Android Chat fallback, not by dashboard login. |
| Dashboard URL | DataStore | Hermes dashboard/admin URL, conventionally the same host as the API server on port `9119`. Derived automatically from the API URL unless explicitly overridden. |
| Dashboard session cookies | EncryptedSharedPreferences | Auth cookies for the dashboard/admin server. Stored separately from API keys and relay session tokens. OAuth/NouS WebView sign-in imports these cookies into the native dashboard client. |
| Relay URL | EncryptedSharedPreferences | WebSocket URL for the Relay Server (optional, for bridge/terminal) |
| Relay Session Token | **Keystore** (StrongBox when available), with fallback to EncryptedSharedPreferences | Persistent token from relay pairing flow. Migrated automatically from the legacy EncryptedSharedPreferences file on first launch post-upgrade. |
| TOFU Cert Pins | DataStore (`tofu_pins`) | SHA-256 SPKI fingerprints per `host:port`. Recorded on the first successful `wss://` connect, verified on subsequent connects via OkHttp `CertificatePinner`. Wiped explicitly when the user re-pairs via QR (taken as consent to new cert material). |
@@ -34,6 +46,10 @@ These are configured during onboarding or from the **Settings → Connections**
| All-plain pairing ack | DataStore (`all_insecure_pair_ack_seen`) | Whether the user has acknowledged the one-time pairing-consent checkbox that appears on step 2 when every route in the scanned QR is plain `ws://` / `http://` (no secure sibling). Per-install. Mixed QRs (LAN + Tailscale) are ungated because the secure route is a safety net. |
| Trusted bridge actions | DataStore (`bridge_trusted_destructive_verbs`) | Set of destructive bridge verbs (e.g. `send_sms`, `call`) that bypass the confirmation overlay because the user ticked "Don't ask again" in a prior confirm. The master-disable toggle and the blocklist still override — trust is for eliminating confirmation fatigue on approved verbs, not a kill-switch bypass. Reset from Bridge → Trusted actions → **Reset**. |
### Backups
**Settings → Developer options → Export Settings** writes a full connection backup. It includes saved connection records, route candidates, preferred route, API keys, relay session tokens, device IDs, paired-session metadata, and dashboard cookies. Treat the exported JSON as sensitive credential material. Importing a backup replaces the saved connection list and restores those connection secrets into the app's encrypted stores.
### Pair Flow — TTL Picker
When you scan a pairing QR (or enter a code manually), a **Session TTL Picker** dialog opens before the phone connects to the relay. Options:
@@ -44,13 +60,13 @@ When you scan a pairing QR (or enter a code manually), a **Session TTL Picker**
- **90 days** / **1 year** — longer-lived operator devices.
- **Never expire** — the device stays paired until you revoke it manually from Relay sessions. Always selectable — the phone treats user intent as the trust model and doesn't gate on transport security. A warning is shown inline.
The default pre-selection depends on the QR's operator-chosen TTL (if any, via `hermes-pair --ttl <duration>`), falling back to 30d on secure/Tailscale transports or 7d on plain ws. Your last pick persists as the new default for future pairs.
The default pre-selection depends on the QR's operator-chosen TTL (if any, via `hermes pair --ttl <duration>`), falling back to 30d on secure/Tailscale transports or 7d on plain ws. Your last pick persists as the new default for future pairs.
Per-channel grants (`chat`, `terminal`, `bridge`, `tui`, `voice:config`, `voice:stt`, `voice:tts`) can be pre-set by the operator via `hermes-pair --grants terminal=7d,bridge=1d,voice:stt=7d`. The phone displays them on the Relay sessions card as labeled chips with a tap-for-info icon explaining that each grant is a per-feature permission with an independent expiry. Grants cannot outlive the session — they're clamped to the session TTL server-side.
Per-channel grants (`chat`, `terminal`, `bridge`, `tui`, `voice:config`, `voice:stt`, `voice:tts`) can be pre-set by the operator via `hermes pair --grants terminal=7d,bridge=1d,voice:stt=7d`. The phone displays them on the Relay sessions card as labeled chips with a tap-for-info icon explaining that each grant is a per-feature permission with an independent expiry. Grants cannot outlive the session — they're clamped to the session TTL server-side.
### Relay sessions
**Settings → Connections → [active card] → Security → Relay sessions** (or **Settings → Relay sessions**) opens a full-screen list of every phone currently paired with the relay. The screen leads with a short intro paragraph explaining that each row is a server-side session (not a Bluetooth pairing, not an account), then renders one card per session:
**Settings → Connections → [active card] → Security → Relay sessions** (or **Settings → Power tools → Relay sessions**) opens a full-screen list of every phone currently paired with the relay. This is a relay power tool: if the current connection is not paired, Android shows **Requires pairing** with a **Pair to unlock** action instead of an empty session list. When paired, the screen leads with a short intro paragraph explaining that each row is a server-side session (not a Bluetooth pairing, not an account), then renders one card per session:
- Device name + device ID
- **Current device** badge if this is the device you're looking at the list on
@@ -136,7 +152,7 @@ The API server is part of `hermes gateway` and configured via `~/.hermes/.env`:
```bash
# Required for Hermes-Relay
API_SERVER_ENABLED=true
# API_SERVER_KEY=your-secret-key # Optional — only set if exposing to network
API_SERVER_KEY=your-secret-key
API_SERVER_HOST=0.0.0.0
API_SERVER_PORT=8642
```
@@ -155,7 +171,7 @@ hermes relay start --no-ssl
python -m plugin.relay --no-ssl
```
`RELAY_HOST` and `RELAY_PORT` are read by **both** the relay server itself and the pair command (`hermes-pair` / `/hermes-relay-pair`) — the pair command uses them to locate the local relay when pre-registering a pairing code, so if you run the relay on a non-default port, make sure the same values are in the environment when you invoke pairing.
`RELAY_HOST` and `RELAY_PORT` are read by **both** the relay server itself and the pair command (`hermes pair` / `/hermes-relay-pair`; `hermes-pair` remains a compatibility shim) — the pair command uses them to locate the local relay when pre-registering a pairing code, so if you run the relay on a non-default port, make sure the same values are in the environment when you invoke pairing.
**Environment variables:**
+5 -5
View File
@@ -134,8 +134,8 @@ hermes-relay insecure-api-key [status|on|off]
| `/ws`, `/` | GET (upgrade) | WebSocket endpoint — phone connects here |
| `/health` | GET | `{status, version, clients, sessions}` JSON |
| `/pairing` | POST | Generate a new relay-side pairing code |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can be embedded in a QR payload. Optional body fields `ttl_seconds` / `grants` / `transport_hint` attach pairing metadata that applies to the session when the phone consumes the code — operator policy wins over phone-sent values. Also **clears all rate-limit blocks on success** so legitimate re-pair after a relay restart works immediately. Used by `/hermes-relay-pair` / `hermes-pair` on the same host. Rejects non-loopback peers with HTTP 403. |
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard, desktop GUI, and CLI pair/repair flows. Reads the API key from the same host-local config chain as `hermes-pair` when not supplied explicitly. |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can be embedded in a QR payload. Optional body fields `ttl_seconds` / `grants` / `transport_hint` attach pairing metadata that applies to the session when the phone consumes the code — operator policy wins over phone-sent values. Also **clears all rate-limit blocks on success** so legitimate re-pair after a relay restart works immediately. Used by `hermes pair` / `/hermes-relay-pair` on the same host; `hermes-pair` remains a compatibility shim. Rejects non-loopback peers with HTTP 403. |
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard, desktop GUI, and CLI pair/repair flows. Reads the API key from the same host-local config chain as `hermes pair` when not supplied explicitly. |
| `/pairing/approve` | POST | **Loopback only, reserved for future use.** Same wire shape as `/pairing/register`. Placeholder for a future phone-generates-code / host-approves flow that would complement the existing QR pairing direction. |
| `/sessions` | GET | Bearer-auth'd (same token the WSS channel uses). Returns all active paired devices with metadata — device name, token prefix (first 8 chars, full token never exposed), created/last-seen timestamps, session expiry, per-channel grants, transport hint, and `is_current` for the device matching the bearer. `math.inf` expiries serialize as `null` (never expire). |
| `/sessions/{token_prefix}` | DELETE | Bearer-auth'd. Revoke a paired device by token-prefix (≥ 4 chars). 200 on exact match, 404 on zero, 409 on ambiguous matches. Self-revoke is allowed and flagged via `revoked_self: true`. |
@@ -214,7 +214,7 @@ As of v0.4 the Device Control surface is **34 routes** (33 excluding the legacy
## Pairing Model
The phone does **not** enter a pairing code by hand. Instead, the pair command (the `/hermes-relay-pair` slash command or the `hermes-pair` shell shim, both running on the Hermes host) drives the whole handshake:
The phone does **not** enter a pairing code by hand. Instead, the pair command (`hermes pair`, the `/hermes-relay-pair` slash command, or the compatibility `hermes-pair` shell shim, all running on the Hermes host) drives the whole handshake:
1. The pair command mints a fresh 6-character code from `A-Z / 0-9`
2. It POSTs the code to `/pairing/register` on the local relay (blocked for any caller outside `127.0.0.1` / `::1`)
@@ -235,8 +235,8 @@ curl http://localhost:8767/health
- **Connection refused** — Is the relay running? `systemctl --user status hermes-relay` (installed via `install.sh`) or `docker logs hermes-relay` (container) or `pgrep -af "python -m plugin.relay"` (manual launch).
- **Voice endpoints 500 with "no API key available"** — The relay process doesn't have the right keys. The Python bootstrap loads `~/.hermes/.env` automatically, so this almost always means the key just isn't in `.env` yet. Double-check with `grep VOICE_TOOLS_OPENAI_KEY ~/.hermes/.env` (for STT) or `grep ELEVENLABS_API_KEY ~/.hermes/.env` (for TTS). If you just edited `.env`, restart the service so Python re-imports: `systemctl --user restart hermes-relay`.
- **Service starts but port bind fails** — Check for an orphan manual launch: `pgrep -f "python -m plugin.relay"`. Kill it with `pkill -f "python -m plugin.relay"` then `systemctl --user restart hermes-relay`.
- **Auth failure** — Pairing codes expire 10 minutes after registration and are one-shot. Re-run `hermes-pair` (or `/hermes-relay-pair`) to mint a fresh code and get a new QR.
- **QR has no relay block** — the pair command only embeds relay details if it can reach `localhost:RELAY_PORT/health` when it runs. Start the relay first, then re-run `hermes-pair`.
- **Auth failure** — Pairing codes expire 10 minutes after registration and are one-shot. Re-run `hermes pair` (or `/hermes-relay-pair`) to mint a fresh code and get a new QR.
- **QR has no relay block** — the pair command only embeds relay details if it can reach `localhost:RELAY_PORT/health` when it runs. Start the relay first, then re-run `hermes pair`.
- **TLS errors** — Use `--no-ssl` for local dev. Ensure cert paths are correct for production.
- **Phone can't reach relay** — Check firewall rules for port 8767. Verify with `curl http://server-ip:8767/health` from another machine.
- **Remote chat or API-key voice fails but relay pairs** — Verify the Hermes API route too: `curl http://server-ip:8642/health`, or `https://<tailnet-host>.ts.net:8642/health` when using Tailscale. Pairing can succeed through `:8767` while chat and API-key voice fail if `:8642` is not published.