A native iPhone / iPad remote for a self-hosted Hermes Agent backend.
The agent runs on your machine; the app is a full Desktop-class client over the dashboard's REST API and
the /api/ws JSON-RPC socket. Nothing is hardcoded: on first launch you enter the URL of your own
hermes serve / hermes dashboard and how you authenticate to it.
glassEffect, GlassEffectContainer, glass button styles, system bars).?profile=.hermes serve or hermes dashboard), reachable from the phone.server/hermes-push companion.Open Vory.xcodeproj, set your team and bundle identifier on the Vory and HermesLiveActivity
targets, and run. The app ships as com.vorantx.vory, with the Live Activity extension as
com.vorantx.vory.LiveActivity.
Running in the simulator needs nothing. Installing on an iPhone or iPad needs a signing identity, and there are three things that commonly block it:
security find-identity -v -p codesigning
must list at least one Apple Development: identity. If it prints 0 valid identities found,
Xcode has to issue a new one: Xcode → Settings → Accounts → select your Apple ID → Manage
Certificates → + → Apple Development. Delete the expired one from Keychain Access first so
Xcode does not keep picking it.aps-environment, and a target that declares it fails with No profiles for '' were
found. This project therefore ships without that entitlement, so a personal team can build
and install. The app notices at runtime and says so in Settings → Notifications; Live Activities
and in-app notifications still work. With a paid membership, add it back the normal way: target
→ Signing & Capabilities → + Capability → Push Notifications.PRODUCT_BUNDLE_IDENTIFIER on both the app and the
HermesLiveActivity target to a reverse-DNS name you control (the extension must stay a child
of the app's id, e.g. com.example.hermesremote and com.example.hermesremote.LiveActivity).Free personal-team provisioning profiles expire after 7 days, so the app stops launching after a
week until you rebuild from Xcode. DEVELOPMENT_TEAM in project.pbxproj holds whichever team
Xcode last wrote there; clear it before sharing the repository.
If Xcode reports "The certificate for this server is invalid" for developerservices2.apple.com,
that is the network in front of you, not the project: a VPN or proxy doing TLS inspection. Verify
from a terminal with
openssl s_client -connect developerservices2.apple.com:443 2>/dev/null | openssl x509 -noout -issuer
— a genuine issuer is Apple Public EV Server RSA CA 1 - G1. Anything else means something is
intercepting, and Xcode is right to refuse. Disconnect that tunnel and retry.
Settings → Gateways → Add Gateway (also the first-launch screen). Enter:
Home.https://hermes.example.com,
https://gateway.example.com/hermes (reverse-proxy prefix), or http://192.168.1.20:9119 on the LAN.
The app strips trailing slashes and pasted /api/... suffixes. Do not enter a chat-webui, an OpenAI
/v1 endpoint, or an SSH host.HERMES_DASHBOARD_SESSION_TOKEN. The app sends it as X-Hermes-Session-Token and as
?token= on the WebSocket.HERMES_DASHBOARD_BASIC_AUTH_*). The app runs the RFC 8252 native flow against
/auth/native/authorize + /auth/password-login + /auth/native/token, keeps only the resulting access +
refresh tokens, and never stores the password./auth/native/token). Tokens refresh through /auth/native/refresh; when the refresh token dies the app asks
you to sign in again and Settings stays reachable.CF-Access-Client-Id and CF-Access-Client-Secret. Both headers are then sent on every HTTP
request and on the WebSocket upgrade. Leave both empty when you have no Access policy; an empty pair does
not bypass anything. Safari's Access cookie is not shared with the app.GET /api/status returns JSON. If it returns HTML you are looking at a login page (Access, a proxy, or a
non-dashboard URL): use the hermes serve URL, add the Access service-token headers, and confirm the tunnel
upgrades WebSockets./api/auth/me for gated gateways, a token-gated endpoint otherwise).wss://…/api/ws opens and gateway.ready arrives. HTTP-pass / WebSocket-fail means the proxy does not
forward Upgrade requests, Access blocks the socket, or dashboard.public_url does not match the host you
typed (DNS-rebinding guard).Telling /api/status JSON from an Access page quickly:
curl -sS -H "CF-Access-Client-Id: <id>" -H "CF-Access-Client-Secret: <secret>" https://hermes.example.com/api/status | head -c 200
JSON starting with {"version": is the dashboard; anything starting with <!DOCTYPE html> is a login page.
Cloudflare Tunnel, Tailscale, plain LAN and public HTTPS are all fine — the app only cares about the URL you type.
Plain http:// is permitted (needed for LAN installs); the form warns when the host is not on a private network.
GET /api/sessions?order=recent), with search, swipe to pin/archive/delete and a
Needs you badge when a card is waiting.session.resume; the compose button calls session.create. Messages go through
prompt.submit; message.delta streams into the bubble; tool.start / tool.complete become glass cards;
message.complete finalizes and drains the local queue.GET /api/model/options grouped by provider. A mid-chat pick sends
config.set model "<model> --provider <slug> --session" — session only, never model.default. Reasoning effort,
fast mode and per-session YOLO live in the same menu./api/profiles entry + GET /api/profiles/{name}/soul);
model picker, context breakdown and bot info live in the … menu.tokens · tok/s · seconds, exact when session.usage gave an
output count before and after the turn, estimated (~) from streamed characters otherwise. Appearance › Chat
toggles tool calls, reasoning, stats and system notes.PUT /api/profiles/{name}/description)
and default model (PUT …/model); SOUL.md opens in an editor that saves with PUT …/soul./api/sessions?profile=). Opening one of its chats switches the selected profile first. Hosted group rooms
(groups.*) are a section underneath when the gateway offers them, with groups.create {name}.{"choice": …} on the same request id (or approval.respond for queued approvals). Clarify, sudo,
secret and vault prompts are handled the same way; secrets use SecureField and are never logged.commands.catalog for suggestions and command.dispatch to run; /approve, /deny,
/stop are handled locally.image.attach_bytes, PDFs → pdf.attach, everything else → file.attach (data URL) and
the returned @file: reference is appended to your message. Hold the mic button for on-device dictation.| Screen | Endpoints |
|---|---|
| Gateways | app-local (Keychain) + Test: /api/status, /api/auth/me, /api/auth/ws-ticket, /api/ws |
| Profile | GET /api/profiles, GET /api/profiles/active, POST /api/profiles |
| Model | GET /api/model/options, GET /api/model/auxiliary, POST /api/model/set |
| Config | GET /api/config, GET /api/config/schema, PUT /api/config {config:{…}} (deep-merge) |
| API keys & env | GET /api/env, PUT /api/env {key,value}, DELETE /api/env {key} |
| Tools | GET /api/tools/toolsets, PUT /api/tools/toolsets/{name} {enabled} |
| Skills | GET /api/skills, PUT /api/skills/toggle {name,enabled} |
| MCP | GET /api/mcp/servers, PUT /api/mcp/servers/{name}/enabled, POST …/test, DELETE …/{name} |
| Approvals | approvals.mode / approvals.timeout via PUT /api/config |
| Cron | GET/POST /api/cron/jobs, `POST …/{id}/pause |
| Sessions | GET /api/sessions, GET /api/sessions/search, GET /api/sessions/stats, DELETE /api/sessions/{id} |
| Channels | GET /api/messaging/platforms (read-only) |
| System | GET /api/status, GET /api/logs (grouped into entries, 5 shown + Show more), POST /api/ops/doctor |
| System › Maintenance | GET /api/hermes/update/check, POST /api/hermes/update, POST /api/gateway/restart, tailed via GET /api/actions/{hermes-update|gateway-restart}/status |
| Appearance (app) | app-local: theme override and which tabs sit in the bottom bar, in what order (tabLayout in UserDefaults) |
| Files tab | GET /api/files, GET /api/files/download, POST /api/files/upload-stream |
Every request carries ?profile=<selected profile>; writes re-GET afterwards and 4xx bodies are shown verbatim.
When the dashboard answers 503 Restart required (it is serving code older than its checkout on disk after a
hermes update or git pull), the app already knows: it probes /api/model/options on connect, on profile
change and after every reconnect, and shows a Liquid Glass banner above the chat list with an Update Hermes
button, badges the Settings tab, and shows the same callout on the Model screen. Restart runs hermes gateway restart; Update runs
hermes update, which relaunches the dashboard. Both execute on the gateway machine and the app reconnects.
Foreground events render inline. Cards and finished turns that arrive while the app is in the background are raised as local notifications for as long as iOS keeps the socket alive. True background delivery uses APNs:
<profile home>/push/devices/<install-id>.json on your gateway (through /api/files/upload).server/hermes-push companion on the gateway machine with your APNs key
(HERMES_PUSH_* variables — see server/hermes-push/README.md).If you build without an Apple Developer team, registration fails gracefully and the app tells you in Settings → Notifications; everything else works.
model.default, never opens MCP/UniFi/LLM ports, and ships no server addresses.Users never create an APNs key. The developer runs the tiny relay in server/push-relay/ (a
Cloudflare Worker holding the APNs key; deploy once, set VORY_PUSH_RELAY_URL in
Tools/release/.env so the release script bakes it into VoryPushRelayURL). Then:
hermes-push on the gateway encrypts each notification (AES-GCM) and posts it to the relay.
The relay looks up the token and forwards to APNs; it sees ciphertext and tokens only.VoryNotificationService/ (a Notification Service Extension) decrypts on the phone and
rewrites the placeholder title/body/category, so actions and deep links work as before.Live Activity updates and watch complication pushes travel through the relay too, with generic content only (no extension can decrypt those). Builds without a relay URL fall back to the bring-your-own-key flow in Settings › Notifications.
Installing the companion from the app: Settings › Notifications › Set up the push
companion… uploads the relay to <home>/plugins/vory-push/ and enables it through
/api/dashboard/agent-plugins/…/enable; Hermes runs it in-process as a plugin after the next
gateway restart (one tap in the same screen). The systemd/launchd installer remains as a fallback.
VoryWatch/, bundle com.vorantx.vory.watchkitapp): recent chats with the ones waiting for
you on top, a chat view that streams through the same VoryCore runtime as the phone, approval / question /
secret cards sized for the wrist, and a dictation composer. Gateways arrive from the iPhone over
WatchConnectivity (every saved gateway plus its secrets, as the application context — encrypted by the
system, latest wins); a session token can also be typed on the watch. Notifications mirrored from the phone
carry the same Approve once / Deny / Reply actions.VoryWatchComplications/, WidgetKit): Needs you (waiting approvals), Current chat
(what the agent is working on) and Context (gauge), in circular, rectangular, inline and corner families.HermesLiveActivity extension. Both read Widgets/VoryWidgets.swift.WidgetSnapshot (attention count, recent chats, context %) into a
Keychain access group shared by the app, its widgets and the watch app (…com.vorantx.vory.shared), so no
App Group is needed. Providers refresh the session list themselves when the snapshot is older than ten
minutes. hermes-push sends the watch complication pushes (throttled to one per three minutes) so faces
update while nothing is open; it never sends the watch alert pushes, because the phone's are mirrored.vory://chat/<id>).GET /api/sessions/{id}/messages and polls, and routes prompts, approvals and
answers through the iPhone over WatchConnectivity (sendMessage wakes the phone app). On Wi-Fi or
cellular the watch uses the live socket directly.Packages/VoryCore — everything that talks to a gateway and holds chat state: networking
(GatewayURL, RequestSigner, HermesAPI, GatewaySocket, NativeAuth), wire types, ChatSession
StreamAssembler, GatewayRuntime, MaintenanceModel, Keychain + ConnectionStore. No UIKit,
AppKit, WatchKit or ActivityKit; it builds for iOS, macOS and watchOS. Platform behaviour is injected
through three hooks in Runtime/Hooks.swift: TurnActivityReporting (Live Activity on iOS),
CardNotifying (local notifications) and PushRegistrationSyncing (device registration).Vory/ — the iOS app: SwiftUI views, Live Activity controller, push registrar, app lock.HermesLiveActivity/ + Shared/ — the iPhone widget extension (Live Activity + home/lock-screen widgets),
the HermesTurnAttributes it shares with the app, and the app icon. Dates in the content state travel as
Unix seconds so the push companion can set them.VoryWatch/, VoryWatchComplications/, Widgets/ — the watch app, its complications extension, and the
WidgetKit code shared by both widget extensions.server/hermes-push/ — the APNs relay you run next to Hermes (see its README): --list, --test,
--dry-run, install.sh. The app bundles a copy (Vory/Resources/hermes-push/, kept identical
by a unit test; Tools/sync-push-companion.sh refreshes it) so Settings › Notifications › Set up the push
companion… can upload it, its config and your APNs key to <profile home>/push/ and ask Hermes to run the
installer. The only thing you do by hand is create the APNs key at Apple.Build the package alone for another platform with
cd Packages/VoryCore && xcodebuild -scheme VoryCore -destination 'generic/platform=macOS' build
(or watchOS Simulator).
VoryTests (Swift Testing): URL normalization, header injection (Access headers absent when empty),
WebSocket ticket/token URL, approval request/response frames, config GET/PUT against a mocked transport, and
streaming delta assembly / code-fence stabilization. Run with ⌘U or
xcodebuild test -project Vory.xcodeproj -scheme Vory -destination 'platform=iOS Simulator,name=iPhone 17 Pro'
Two optional end-to-end suites run against a real gateway when the simulator's environment carries
HERMES_E2E_URL and HERMES_E2E_TOKEN (they skip otherwise):
GatewayIntegrationTests drives the app's networking stack: the three-leg connection test, REST decoding,
client.capabilities, session.create, prompt.submit, message.complete, usage/context RPCs, then deletes
the session it created.VoryUITests drives the real UI: onboarding → form → Test Connection → Save → new chat → send →
Settings → Tools / Config / Env, saving screenshots to the runner's tmp directory.UDID=<simulator udid>
xcrun simctl boot $UDID
xcrun simctl spawn $UDID launchctl setenv HERMES_E2E_URL http://127.0.0.1:9119
xcrun simctl spawn $UDID launchctl setenv HERMES_E2E_TOKEN "$HERMES_DASHBOARD_SESSION_TOKEN"
xcodebuild test -project Vory.xcodeproj -scheme Vory -destination "id=$UDID" -parallel-testing-enabled NO
The streaming assertion is skipped (and reported) when the gateway has no AI provider configured.
Tools/release/testflight.sh archives and uploads a build without any interactive Apple login,
using an App Store Connect API key instead of an Apple ID password and 2FA. It derives a fresh,
monotonic build number from the clock each run, because App Store Connect refuses a
(version, build) pair it has already seen.
export ASC_KEY_ID=ABCD123456
export ASC_ISSUER_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
export ASC_KEY_PATH=$HOME/.appstoreconnect/private_keys/AuthKey_ABCD123456.p8
Tools/release/testflight.sh
Put those in Tools/release/.env instead if you prefer; that path is gitignored. The .p8 itself
should live outside the repository.
Three things must happen once, in a browser, before the first run, because Apple offers no other route for them:
com.vorantx.vory.App Store Connect fields for that record:
| Field | Value |
|---|---|
| Name (30 char limit) | Vory: Hermes Agent UI |
| Bundle ID | com.vorantx.vory |
| SKU | vory-ios |
| Primary language | English (U.S.) |
The home screen name stays Vory via CFBundleDisplayName; iOS truncates anything longer. Only
the listing carries the descriptive form.
Everything after that is unattended. Apple occasionally introduces a new agreement that silently blocks uploads until it is accepted, so an upload that suddenly fails on a previously working setup is worth checking there first.
Tools/mock-gateway/mock_gateway.py is a protocol-faithful fake dashboard: the REST endpoints the
app reads plus the /api/ws JSON-RPC surface, including a scripted turn that streams
message.delta token by token, runs two terminal tool calls, asks for an approval and finishes
with message.complete and usage. It needs no AI provider, no API keys and makes no network calls,
so the whole chat surface can be developed and screenshotted offline.
python3 Tools/mock-gateway/mock_gateway.py --port 9119 --token mock-token
Then add a gateway in the app pointing at http://127.0.0.1:9119 with that session token.
ChatShowcaseUITests drives a full conversation against it and saves screenshots of the streaming
state, the tool cards, the approval card, the finished transcript, the context sheet and the model
picker.
87 commits
Swift
79.1%
Python
18.7%
Shell
1.5%
A native iPhone / iPad remote for a self-hosted Hermes Agent backend.
The agent runs on your machine; the app is a full Desktop-class client over the dashboard's REST API and
the /api/ws JSON-RPC socket. Nothing is hardcoded: on first launch you enter the URL of your own
hermes serve / hermes dashboard and how you authenticate to it.
glassEffect, GlassEffectContainer, glass button styles, system bars).?profile=.hermes serve or hermes dashboard), reachable from the phone.server/hermes-push companion.Open Vory.xcodeproj, set your team and bundle identifier on the Vory and HermesLiveActivity
targets, and run. The app ships as com.vorantx.vory, with the Live Activity extension as
com.vorantx.vory.LiveActivity.
Running in the simulator needs nothing. Installing on an iPhone or iPad needs a signing identity, and there are three things that commonly block it:
security find-identity -v -p codesigning
must list at least one Apple Development: identity. If it prints 0 valid identities found,
Xcode has to issue a new one: Xcode → Settings → Accounts → select your Apple ID → Manage
Certificates → + → Apple Development. Delete the expired one from Keychain Access first so
Xcode does not keep picking it.aps-environment, and a target that declares it fails with No profiles for '' were
found. This project therefore ships without that entitlement, so a personal team can build
and install. The app notices at runtime and says so in Settings → Notifications; Live Activities
and in-app notifications still work. With a paid membership, add it back the normal way: target
→ Signing & Capabilities → + Capability → Push Notifications.PRODUCT_BUNDLE_IDENTIFIER on both the app and the
HermesLiveActivity target to a reverse-DNS name you control (the extension must stay a child
of the app's id, e.g. com.example.hermesremote and com.example.hermesremote.LiveActivity).Free personal-team provisioning profiles expire after 7 days, so the app stops launching after a
week until you rebuild from Xcode. DEVELOPMENT_TEAM in project.pbxproj holds whichever team
Xcode last wrote there; clear it before sharing the repository.
If Xcode reports "The certificate for this server is invalid" for developerservices2.apple.com,
that is the network in front of you, not the project: a VPN or proxy doing TLS inspection. Verify
from a terminal with
openssl s_client -connect developerservices2.apple.com:443 2>/dev/null | openssl x509 -noout -issuer
— a genuine issuer is Apple Public EV Server RSA CA 1 - G1. Anything else means something is
intercepting, and Xcode is right to refuse. Disconnect that tunnel and retry.
Settings → Gateways → Add Gateway (also the first-launch screen). Enter:
Home.https://hermes.example.com,
https://gateway.example.com/hermes (reverse-proxy prefix), or http://192.168.1.20:9119 on the LAN.
The app strips trailing slashes and pasted /api/... suffixes. Do not enter a chat-webui, an OpenAI
/v1 endpoint, or an SSH host.HERMES_DASHBOARD_SESSION_TOKEN. The app sends it as X-Hermes-Session-Token and as
?token= on the WebSocket.HERMES_DASHBOARD_BASIC_AUTH_*). The app runs the RFC 8252 native flow against
/auth/native/authorize + /auth/password-login + /auth/native/token, keeps only the resulting access +
refresh tokens, and never stores the password./auth/native/token). Tokens refresh through /auth/native/refresh; when the refresh token dies the app asks
you to sign in again and Settings stays reachable.CF-Access-Client-Id and CF-Access-Client-Secret. Both headers are then sent on every HTTP
request and on the WebSocket upgrade. Leave both empty when you have no Access policy; an empty pair does
not bypass anything. Safari's Access cookie is not shared with the app.GET /api/status returns JSON. If it returns HTML you are looking at a login page (Access, a proxy, or a
non-dashboard URL): use the hermes serve URL, add the Access service-token headers, and confirm the tunnel
upgrades WebSockets./api/auth/me for gated gateways, a token-gated endpoint otherwise).wss://…/api/ws opens and gateway.ready arrives. HTTP-pass / WebSocket-fail means the proxy does not
forward Upgrade requests, Access blocks the socket, or dashboard.public_url does not match the host you
typed (DNS-rebinding guard).Telling /api/status JSON from an Access page quickly:
curl -sS -H "CF-Access-Client-Id: <id>" -H "CF-Access-Client-Secret: <secret>" https://hermes.example.com/api/status | head -c 200
JSON starting with {"version": is the dashboard; anything starting with <!DOCTYPE html> is a login page.
Cloudflare Tunnel, Tailscale, plain LAN and public HTTPS are all fine — the app only cares about the URL you type.
Plain http:// is permitted (needed for LAN installs); the form warns when the host is not on a private network.
GET /api/sessions?order=recent), with search, swipe to pin/archive/delete and a
Needs you badge when a card is waiting.session.resume; the compose button calls session.create. Messages go through
prompt.submit; message.delta streams into the bubble; tool.start / tool.complete become glass cards;
message.complete finalizes and drains the local queue.GET /api/model/options grouped by provider. A mid-chat pick sends
config.set model "<model> --provider <slug> --session" — session only, never model.default. Reasoning effort,
fast mode and per-session YOLO live in the same menu./api/profiles entry + GET /api/profiles/{name}/soul);
model picker, context breakdown and bot info live in the … menu.tokens · tok/s · seconds, exact when session.usage gave an
output count before and after the turn, estimated (~) from streamed characters otherwise. Appearance › Chat
toggles tool calls, reasoning, stats and system notes.PUT /api/profiles/{name}/description)
and default model (PUT …/model); SOUL.md opens in an editor that saves with PUT …/soul./api/sessions?profile=). Opening one of its chats switches the selected profile first. Hosted group rooms
(groups.*) are a section underneath when the gateway offers them, with groups.create {name}.{"choice": …} on the same request id (or approval.respond for queued approvals). Clarify, sudo,
secret and vault prompts are handled the same way; secrets use SecureField and are never logged.commands.catalog for suggestions and command.dispatch to run; /approve, /deny,
/stop are handled locally.image.attach_bytes, PDFs → pdf.attach, everything else → file.attach (data URL) and
the returned @file: reference is appended to your message. Hold the mic button for on-device dictation.| Screen | Endpoints |
|---|---|
| Gateways | app-local (Keychain) + Test: /api/status, /api/auth/me, /api/auth/ws-ticket, /api/ws |
| Profile | GET /api/profiles, GET /api/profiles/active, POST /api/profiles |
| Model | GET /api/model/options, GET /api/model/auxiliary, POST /api/model/set |
| Config | GET /api/config, GET /api/config/schema, PUT /api/config {config:{…}} (deep-merge) |
| API keys & env | GET /api/env, PUT /api/env {key,value}, DELETE /api/env {key} |
| Tools | GET /api/tools/toolsets, PUT /api/tools/toolsets/{name} {enabled} |
| Skills | GET /api/skills, PUT /api/skills/toggle {name,enabled} |
| MCP | GET /api/mcp/servers, PUT /api/mcp/servers/{name}/enabled, POST …/test, DELETE …/{name} |
| Approvals | approvals.mode / approvals.timeout via PUT /api/config |
| Cron | GET/POST /api/cron/jobs, `POST …/{id}/pause |
| Sessions | GET /api/sessions, GET /api/sessions/search, GET /api/sessions/stats, DELETE /api/sessions/{id} |
| Channels | GET /api/messaging/platforms (read-only) |
| System | GET /api/status, GET /api/logs (grouped into entries, 5 shown + Show more), POST /api/ops/doctor |
| System › Maintenance | GET /api/hermes/update/check, POST /api/hermes/update, POST /api/gateway/restart, tailed via GET /api/actions/{hermes-update|gateway-restart}/status |
| Appearance (app) | app-local: theme override and which tabs sit in the bottom bar, in what order (tabLayout in UserDefaults) |
| Files tab | GET /api/files, GET /api/files/download, POST /api/files/upload-stream |
Every request carries ?profile=<selected profile>; writes re-GET afterwards and 4xx bodies are shown verbatim.
When the dashboard answers 503 Restart required (it is serving code older than its checkout on disk after a
hermes update or git pull), the app already knows: it probes /api/model/options on connect, on profile
change and after every reconnect, and shows a Liquid Glass banner above the chat list with an Update Hermes
button, badges the Settings tab, and shows the same callout on the Model screen. Restart runs hermes gateway restart; Update runs
hermes update, which relaunches the dashboard. Both execute on the gateway machine and the app reconnects.
Foreground events render inline. Cards and finished turns that arrive while the app is in the background are raised as local notifications for as long as iOS keeps the socket alive. True background delivery uses APNs:
<profile home>/push/devices/<install-id>.json on your gateway (through /api/files/upload).server/hermes-push companion on the gateway machine with your APNs key
(HERMES_PUSH_* variables — see server/hermes-push/README.md).If you build without an Apple Developer team, registration fails gracefully and the app tells you in Settings → Notifications; everything else works.
model.default, never opens MCP/UniFi/LLM ports, and ships no server addresses.Users never create an APNs key. The developer runs the tiny relay in server/push-relay/ (a
Cloudflare Worker holding the APNs key; deploy once, set VORY_PUSH_RELAY_URL in
Tools/release/.env so the release script bakes it into VoryPushRelayURL). Then:
hermes-push on the gateway encrypts each notification (AES-GCM) and posts it to the relay.
The relay looks up the token and forwards to APNs; it sees ciphertext and tokens only.VoryNotificationService/ (a Notification Service Extension) decrypts on the phone and
rewrites the placeholder title/body/category, so actions and deep links work as before.Live Activity updates and watch complication pushes travel through the relay too, with generic content only (no extension can decrypt those). Builds without a relay URL fall back to the bring-your-own-key flow in Settings › Notifications.
Installing the companion from the app: Settings › Notifications › Set up the push
companion… uploads the relay to <home>/plugins/vory-push/ and enables it through
/api/dashboard/agent-plugins/…/enable; Hermes runs it in-process as a plugin after the next
gateway restart (one tap in the same screen). The systemd/launchd installer remains as a fallback.
VoryWatch/, bundle com.vorantx.vory.watchkitapp): recent chats with the ones waiting for
you on top, a chat view that streams through the same VoryCore runtime as the phone, approval / question /
secret cards sized for the wrist, and a dictation composer. Gateways arrive from the iPhone over
WatchConnectivity (every saved gateway plus its secrets, as the application context — encrypted by the
system, latest wins); a session token can also be typed on the watch. Notifications mirrored from the phone
carry the same Approve once / Deny / Reply actions.VoryWatchComplications/, WidgetKit): Needs you (waiting approvals), Current chat
(what the agent is working on) and Context (gauge), in circular, rectangular, inline and corner families.HermesLiveActivity extension. Both read Widgets/VoryWidgets.swift.WidgetSnapshot (attention count, recent chats, context %) into a
Keychain access group shared by the app, its widgets and the watch app (…com.vorantx.vory.shared), so no
App Group is needed. Providers refresh the session list themselves when the snapshot is older than ten
minutes. hermes-push sends the watch complication pushes (throttled to one per three minutes) so faces
update while nothing is open; it never sends the watch alert pushes, because the phone's are mirrored.vory://chat/<id>).GET /api/sessions/{id}/messages and polls, and routes prompts, approvals and
answers through the iPhone over WatchConnectivity (sendMessage wakes the phone app). On Wi-Fi or
cellular the watch uses the live socket directly.Packages/VoryCore — everything that talks to a gateway and holds chat state: networking
(GatewayURL, RequestSigner, HermesAPI, GatewaySocket, NativeAuth), wire types, ChatSession
StreamAssembler, GatewayRuntime, MaintenanceModel, Keychain + ConnectionStore. No UIKit,
AppKit, WatchKit or ActivityKit; it builds for iOS, macOS and watchOS. Platform behaviour is injected
through three hooks in Runtime/Hooks.swift: TurnActivityReporting (Live Activity on iOS),
CardNotifying (local notifications) and PushRegistrationSyncing (device registration).Vory/ — the iOS app: SwiftUI views, Live Activity controller, push registrar, app lock.HermesLiveActivity/ + Shared/ — the iPhone widget extension (Live Activity + home/lock-screen widgets),
the HermesTurnAttributes it shares with the app, and the app icon. Dates in the content state travel as
Unix seconds so the push companion can set them.VoryWatch/, VoryWatchComplications/, Widgets/ — the watch app, its complications extension, and the
WidgetKit code shared by both widget extensions.server/hermes-push/ — the APNs relay you run next to Hermes (see its README): --list, --test,
--dry-run, install.sh. The app bundles a copy (Vory/Resources/hermes-push/, kept identical
by a unit test; Tools/sync-push-companion.sh refreshes it) so Settings › Notifications › Set up the push
companion… can upload it, its config and your APNs key to <profile home>/push/ and ask Hermes to run the
installer. The only thing you do by hand is create the APNs key at Apple.Build the package alone for another platform with
cd Packages/VoryCore && xcodebuild -scheme VoryCore -destination 'generic/platform=macOS' build
(or watchOS Simulator).
VoryTests (Swift Testing): URL normalization, header injection (Access headers absent when empty),
WebSocket ticket/token URL, approval request/response frames, config GET/PUT against a mocked transport, and
streaming delta assembly / code-fence stabilization. Run with ⌘U or
xcodebuild test -project Vory.xcodeproj -scheme Vory -destination 'platform=iOS Simulator,name=iPhone 17 Pro'
Two optional end-to-end suites run against a real gateway when the simulator's environment carries
HERMES_E2E_URL and HERMES_E2E_TOKEN (they skip otherwise):
GatewayIntegrationTests drives the app's networking stack: the three-leg connection test, REST decoding,
client.capabilities, session.create, prompt.submit, message.complete, usage/context RPCs, then deletes
the session it created.VoryUITests drives the real UI: onboarding → form → Test Connection → Save → new chat → send →
Settings → Tools / Config / Env, saving screenshots to the runner's tmp directory.UDID=<simulator udid>
xcrun simctl boot $UDID
xcrun simctl spawn $UDID launchctl setenv HERMES_E2E_URL http://127.0.0.1:9119
xcrun simctl spawn $UDID launchctl setenv HERMES_E2E_TOKEN "$HERMES_DASHBOARD_SESSION_TOKEN"
xcodebuild test -project Vory.xcodeproj -scheme Vory -destination "id=$UDID" -parallel-testing-enabled NO
The streaming assertion is skipped (and reported) when the gateway has no AI provider configured.
Tools/release/testflight.sh archives and uploads a build without any interactive Apple login,
using an App Store Connect API key instead of an Apple ID password and 2FA. It derives a fresh,
monotonic build number from the clock each run, because App Store Connect refuses a
(version, build) pair it has already seen.
export ASC_KEY_ID=ABCD123456
export ASC_ISSUER_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
export ASC_KEY_PATH=$HOME/.appstoreconnect/private_keys/AuthKey_ABCD123456.p8
Tools/release/testflight.sh
Put those in Tools/release/.env instead if you prefer; that path is gitignored. The .p8 itself
should live outside the repository.
Three things must happen once, in a browser, before the first run, because Apple offers no other route for them:
com.vorantx.vory.App Store Connect fields for that record:
| Field | Value |
|---|---|
| Name (30 char limit) | Vory: Hermes Agent UI |
| Bundle ID | com.vorantx.vory |
| SKU | vory-ios |
| Primary language | English (U.S.) |
The home screen name stays Vory via CFBundleDisplayName; iOS truncates anything longer. Only
the listing carries the descriptive form.
Everything after that is unattended. Apple occasionally introduces a new agreement that silently blocks uploads until it is accepted, so an upload that suddenly fails on a previously working setup is worth checking there first.
Tools/mock-gateway/mock_gateway.py is a protocol-faithful fake dashboard: the REST endpoints the
app reads plus the /api/ws JSON-RPC surface, including a scripted turn that streams
message.delta token by token, runs two terminal tool calls, asks for an approval and finishes
with message.complete and usage. It needs no AI provider, no API keys and makes no network calls,
so the whole chat surface can be developed and screenshotted offline.
python3 Tools/mock-gateway/mock_gateway.py --port 9119 --token mock-token
Then add a gateway in the app pointing at http://127.0.0.1:9119 with that session token.
ChatShowcaseUITests drives a full conversation against it and saves screenshots of the streaming
state, the tool cards, the approval card, the finished transcript, the context sheet and the model
picker.
87 commits
Swift
79.1%
Python
18.7%
Shell
1.5%