30 KiB
Keryx — iOS/iPad Hermes Client Design Document
Project: Keryx (iOS/iPad native Hermes client)
BFF: Keryx BFF (keryx-bff, Python FastAPI + Strawberry GraphQL)
Status: v3 — iOS app refined ✅
Date: 2026-07-03
Table of Contents
- Overview
- Architecture
- iOS App
- BFF (Backend For Frontend)
- API Contract
- Data Storage
- Security
- Deployment
- MVP Feature Matrix
- Post-MVP Roadmap
1. Overview
Keryx is a native iOS/iPadOS app that replaces the Hermes WebUI. It connects to a user's self-hosted Hermes Agent instance via a Python BFF service. The BFF owns the app experience — it provides page-shaped responses (feed of UI components) rather than raw data. The iOS app is a native component renderer using TCA for state management.
Key Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Minimum iOS version | iOS 20 beta | Strict Swift 6 concurrency, latest SwiftUI, on-device ML |
| App architecture | TCA (The Composable Architecture) | Central store for multi-session state, SSE event→action mapping, testability |
| App state management | Feature-scoped reducers compose into AppReducer |
Each screen independent, parent routes navigation |
| GraphQL client | None — raw URLSession + Codable |
No Apollo overhead; AI generates DTOs from schema |
| SSE streaming | Effect.run + for try await |
Idiomatic TCA pattern, native URLSession bytes |
| SSE parser | Inline, ~50 lines | Zero dependencies, no external package |
| Page rendering | Per-screen ForEach |
Each module owns its rendering; no shared FeedView switch |
| UI models | DTO → Mapper → Canonical per module | Schema changes only touch DTO + Mapper, View never references DTO |
| BFF language | Python (FastAPI + Strawberry) | Same language as Hermes, GraphQL for version-safe queries |
| API style | Hybrid GraphQL + SSE | GraphQL for page data, SSE for chat streaming |
| Chat protocol | Hermes /api/sessions/{id}/chat/stream |
Native session-managed streaming |
| SSE events | component, chunk, component_patch, component_complete, done |
Maps 1:1 to feed array mutations |
| Streaming transport | SSE | Works through proxies, native URLSession support |
| Auth | Bearer token in iOS Keychain | Matches Hermes API Server auth model |
| Connection setup | Native SwiftUI screen + QR code scan | VisionKit camera, QR encodes URL + API key |
| QR generation | scripts/generate-setup-qr.py in BFF repo |
Run by user or Hermes agent on the server |
| Deep links | Internal keryx:// only |
All links generated by BFF; external link mapping is post-MVP |
| Pagination | None — load full lists | 82 sessions currently; ~3 MB even for 10,000 sessions |
| Deploy model | systemd user service | Matches Hermes gateway deployment pattern |
| Dependencies | uv for Python, loguru for logging | Modern, fast, clean |
| File staging cleanup | Lifespan scheduled task — daily, delete files >30 days | Automatic |
| Conversation mappings | Never deleted | Simpler lifecycle, Hermes manages sessions |
2. Architecture
┌─────────────────┐ GraphQL + SSE ┌─────────────────┐ HTTP ┌─────────────────┐
│ │──────────────────→│ │──────────────→│ │
│ iOS / iPad │ app_data, pages │ Keryx BFF │ Hermes API │ Hermes Agent │
│ (SwiftUI) │←──────────────────│ (FastAPI) │←──────────────│ (API Server) │
│ + TCA │ SSE feed stream │ Strawberry GQL │ SSE stream │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
│ filesystem
▼
┌─────────────────┐
│ Workspace dir │
│ (read-only) │
└─────────────────┘
Key Design Principles
- The BFF owns the app experience — titles, labels, layout, navigation, empty states all come from the server
- The iOS app is a component renderer — it receives typed UI components (
session_card,message_bubble,tool_call_card, etc.) and renders them natively - Version-safe via GraphQL — old apps never query for component types they can't render
- Feed array pattern — every page is
{header, items[], footer}whereitemsis a heterogeneous array of typed components - Canonical UI models per module — DTOs from GraphQL are mapped to module-local canonical types; schema changes only affect DTO + Mapper
3. iOS App
Tech Stack
| Layer | Technology |
|---|---|
| Architecture | TCA (swift-composable-architecture, swift-dependencies) |
| UI Framework | SwiftUI (iOS 20 SDK) |
| Navigation | NavigationSplitView (adaptive: iPhone push, iPad multi-column) |
| Networking | Raw URLSession + Codable for GraphQL (no Apollo) |
| SSE reader | Inline parser (~50 lines), URLSession.bytes async sequence |
| State management | Feature-scoped @Reducer composable in AppReducer |
| Local storage | Keychain (credentials), UserDefaults (preferences) |
| Audio | AVSpeechRecognizer (on-device STT) |
| Camera / QR | VisionKit.DataScannerViewController (iOS 20 native) |
| File picker | UIDocumentPickerViewController / FileDocument |
| Drag & drop | DropDelegate on compose area (iPad) |
| Markdown rendering | Custom renderer per component type |
| Deep links | keryx:// URL scheme, parsed in AppReducer |
Project Structure
Keryx/
├── KeryxApp.swift # @main, store init
├── App/
│ ├── Reducers/
│ │ └── AppReducer.swift # Root reducer, navigation, deep links
│ ├── Views/
│ │ ├── MainSplitView.swift # NavigationSplitView + tab routing
│ │ └── DeepLinkHandler.swift # keryx:// URL parsing + dispatch
│ └── Models/
│ └── AppState.swift # Root app state, composed from modules
│
├── Shared/ # Cross-cutting, no screen-specific logic
│ ├── Networking/
│ │ ├── BFFClient.swift # URLSession + GraphQL query runner
│ │ ├── SSEReader.swift # ~50 line SSE parser
│ │ ├── AuthMiddleware.swift # Bearer token injection
│ │ └── DTOs/ # Raw GraphQL response types
│ │ ├── AppDataDTO.swift
│ │ ├── PageDTO.swift
│ │ └── SessionDTO.swift
│ ├── Models/
│ │ ├── FeedComponent.swift # Canonical UI enum (shared by all modules)
│ │ ├── DeepLink.swift # Parsed deep link enum
│ │ └── AppData.swift # Canonical app data model
│ ├── Services/
│ │ ├── KeychainService.swift
│ │ └── AudioService.swift
│ └── Views/
│ ├── CodeBlockView.swift
│ ├── EmptyStateView.swift
│ └── ErrorBannerView.swift
│
├── Sessions/ # Module: sessions list
│ ├── Reducers/
│ │ └── SessionsListReducer.swift
│ ├── Views/
│ │ └── SessionCardView.swift # Renders session_card component
│ ├── Models/
│ │ └── SessionCard.swift # Canonical UI model
│ └── Services/
│ └── SessionsMapper.swift # DTO → SessionCard
│
├── Chat/ # Module: chat view
│ ├── Reducers/
│ │ └── ChatReducer.swift # SSE event → feed state
│ ├── Views/
│ │ ├── ChatView.swift # ForEach over feed, includes compose bar
│ │ ├── MessageBubbleView.swift
│ │ ├── ThinkingBlockView.swift
│ │ ├── ToolCallCardView.swift
│ │ └── ComposeBarView.swift
│ ├── Models/
│ │ ├── ChatState.swift # Canonical chat state
│ │ └── SSEEvent.swift # Parsed SSE event (component, chunk, etc.)
│ └── Services/
│ └── ChatMapper.swift # SSE event → FeedComponent
│
├── Workspace/ # Module: file browser
│ ├── Reducers/
│ │ └── WorkspaceReducer.swift
│ ├── Views/
│ │ └── FileRowView.swift # Renders file_row component
│ ├── Models/
│ │ └── FileNode.swift # Canonical file model
│ └── Services/
│ └── WorkspaceMapper.swift # DTO → FileNode
│
├── Settings/ # Module: connection config
│ ├── Reducers/
│ │ └── SettingsReducer.swift
│ ├── Views/
│ │ ├── SetupView.swift # First-launch connection screen
│ │ └── QRScannerView.swift # VisionKit camera scanner
│ └── Models/
│ └── ConnectionConfig.swift # Canonical config model
│
└── Resources/
├── Assets.xcassets
└── Info.plist
Module Rules
| Rule | Why |
|---|---|
Shared/Networking/DTOs/ contain raw GraphQL types |
Single point of change when schema evolves |
Each module has own Models/ with canonical types |
No module imports another module's models |
Each module has Services/*Mapper.swift |
Mapping DTO → canonical model is explicit and testable |
FeedComponent.swift lives in Shared/Models/ |
All modules produce feed items — one canonical enum |
| Mapper never touches a View | Separation of concerns |
| Reducer only operates on canonical models | Schema change = DTO + Mapper update only, reducer tests stay green |
Per-screen ForEach loops |
Each screen owns rendering independently; no shared FeedView |
TCA Architecture
The AppReducer composes feature-scoped reducers and handles navigation via StackState:
@Reducer
struct AppReducer {
@ObservableState
struct State: Equatable {
var path = StackState<Path.State>()
var sessionsList = SessionsListReducer.State()
var settings = SettingsReducer.State()
}
enum Action {
case path(StackActionOf<Path>)
case sessionsList(SessionsListReducer.Action)
case settings(SettingsReducer.Action)
case deepLink(URL)
}
var body: some ReducerOf<Self> {
Scope(state: \.sessionsList, action: \.sessionsList) {
SessionsListReducer()
}
Scope(state: \.settings, action: \.settings) {
SettingsReducer()
}
Reduce { state, action in
switch action {
case .deepLink(let url):
// Parse keryx:// → route navigation or dispatch action
return .none
// ...
}
}
.forEach(\.path, action: \.path)
}
}
Each feature reducer (e.g. ChatReducer) receives SSE events through TCA's Effect.run:
case .sendMessage(let text):
return .run { [sessionId = state.sessionId] send in
let stream = try await bffClient.streamChat(sessionId, text)
for try await event in stream {
switch event {
case .component(let type, let id, let data):
await send(.feedEvent(.componentReceived(type, id, data)))
case .chunk(let componentId, let text):
await send(.feedEvent(.chunkReceived(componentId, text)))
case .componentPatch(let id, let data):
await send(.feedEvent(.patchReceived(id, data)))
case .componentComplete(let id, let usage):
await send(.feedEvent(.completeReceived(id, usage)))
case .done:
await send(.feedEvent(.streamComplete))
case .error(let code, let message):
await send(.feedEvent(.streamError(code, message)))
}
}
}
Connection Setup Flow
First launch:
1. App launches → no stored credentials → show SetupView (native SwiftUI)
2. User enters BFF URL + API Key manually, OR taps "Scan QR"
3. QR scan: VisionKit camera reads keryx:// URL → decodes JSON → fills fields
4. Tap "Connect" → POST to BFF /v1/ios/health to validate
5. Success → save to Keychain → fetch app_data → render main app
6. Failure → show error, fields stay filled, user can retry
Subsequent launches & foregrounds:
1. Read credentials from Keychain
2. Fetch /v1/ios/app_data
3. If 401 → show SetupView (credentials expired)
4. If 200 → render main app from app_data
SetupView is the only hardcoded screen in the app. Every other screen comes from BFF endpoints.
Deep Link Routing
All navigation is driven by keryx:// deep links. Links come from BFF responses (header.actions, component actions, footer). The AppReducer parses and routes them:
case .deepLink(let url):
switch url.toDeepLink() {
case .chat(let id):
state.path.append(.chat(ChatReducer.State(sessionId: id)))
case .deleteSession(let id):
return .send(.sessionsList(.deleteSession(id)))
case .newChat:
return .run { send in
let session = try await bffClient.createSession()
await send(.deepLink(URL(string: "keryx://chat/\(session.id)")!))
}
case .sessions:
state.path.removeAll()
// ...
}
Deep link registry (see DOMAIN.md for full list): keryx://sessions, keryx://new-chat, keryx://chat/{id}, keryx://delete/{id}, keryx://rename/{id}, keryx://workspace, keryx://workspace/{path}, keryx://settings.
Per-Screen Rendering
Each screen module renders its own feed via a direct ForEach loop — no shared FeedView switch:
// SessionsListView (in Sessions/Views/)
var body: some View {
List {
ForEach(store.items, id: \.id) { component in
SessionCardView(component: component)
}
if let emptyState = store.emptyState {
EmptyStateView(emptyState: emptyState)
}
}
}
// ChatView (in Chat/Views/)
var body: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(store.feed, id: \.id) { component in
switch component.type {
case .messageBubble:
MessageBubbleView(component: component)
case .thinkingBlock:
ThinkingBlockView(component: component)
case .toolCallCard:
ToolCallCardView(component: component)
case .codeBlock:
CodeBlockView(component: component)
default:
EmptyView()
}
}
}
}
.safeAreaInset(edge: .bottom) {
ComposeBarView(...)
}
}
Unknown component types are silently skipped via default: EmptyView().
SSE Reader (Inline Parser)
The SSE parser is ~50 lines, lives in Shared/Networking/SSEReader.swift:
struct SSEEvent: Equatable {
enum Name: String {
case component, chunk, componentPatch = "component_patch",
componentComplete = "component_complete", done, error
}
let name: Name
let data: Data
}
extension AsyncSequence where Element == UInt8 {
func sseEvents() -> AsyncStream<SSEEvent> {
AsyncStream { continuation in
// Split on "\n\n", parse "event:" and "data:" lines
// Decode data JSON
// continuation.yield(SSEEvent(name: ..., data: ...))
}
}
}
Usage in ChatReducer:
let stream = try await session.bytes(for: request)
.sseEvents()
for try await event in stream { ... }
Key Screens
| Screen | Source | Component Types | Module |
|---|---|---|---|
| Connection Setup | First launch (native) | N/A — hardcoded fields + QR scanner | Settings/ |
| Sessions List | app_data → page(id: "sessions") |
session_card |
Sessions/ |
| Chat View | keryx://chat/{id} deep link |
message_bubble, thinking_block, tool_call_card, code_block + compose_bar footer |
Chat/ |
| Workspace Files | Tab link → page(id: "workspace") |
file_row |
Workspace/ |
| Settings | Tab link | Server-driven (post-MVP native) | Settings/ |
4. BFF (Backend For Frontend)
Identity
- Name:
keryx-bff - Stack: Python 3.12+, FastAPI + Strawberry GraphQL + uvicorn
- Config file:
~/.keryx/config.yaml - Service file:
~/.config/systemd/user/keryx-bff.service
Project Structure
keryx-bff/
├── pyproject.toml
├── README.md
├── scripts/
│ └── generate-setup-qr.py # Reads config.yaml → QR code PNG
├── src/
│ ├── server.py # FastAPI app, lifespan, router/GraphQL includes
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py # Pydantic config model (load YAML)
│ │ ├── auth.py # Bearer token middleware
│ │ ├── lifespan.py # App startup/shutdown lifecycle
│ │ ├── db.py # SQLite for keryx.db (WAL mode)
│ │ └── exceptions.py # Global exception handlers
│ ├── models/
│ │ ├── __init__.py
│ │ ├── graphql.py # Strawberry schema: Query, Mutation
│ │ ├── app_data.py # AppData, Navigation, Tab, FeatureFlags
│ │ ├── page.py # Page, Header, Footer, FeedComponent union
│ │ ├── components.py # SessionCard, MessageBubble, ToolCallCard, etc.
│ │ ├── chat.py # Chat request + SSE event models
│ │ ├── workspace.py # FileNode, FileContent
│ │ └── health.py # HealthResponse
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── app_data.py # GET /v1/ios/app_data
│ │ ├── chat_stream.py # POST /v1/ios/chat/{id}/stream (SSE)
│ │ ├── attach.py # POST /v1/ios/chat/{id}/attach
│ │ └── workspace.py # GET /v1/ios/workspace/*
│ ├── services/
│ │ ├── __init__.py
│ │ ├── hermes_client.py # httpx async client for Hermes API
│ │ ├── page_builder.py # Build feed arrays from Hermes data
│ │ └── staging.py # File upload staging + cleanup schedule
│ └── lib/
│ ├── __init__.py
│ └── deep_link.py # Link builder helpers
└── tests/
├── conftest.py
├── test_app_data.py
├── test_chat_stream.py
├── test_attach.py
├── test_workspace.py
└── test_graphql.py
Dependencies (pyproject.toml)
[project]
name = "keryx-bff"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"strawberry-graphql>=0.260",
"strawberry-graphql[fastapi]",
"uvicorn[standard]",
"pydantic>=2.0",
"pydantic-settings",
"httpx>=0.28",
"python-multipart",
"pyyaml",
"loguru",
"aiosqlite",
"qrcode", # for generate-setup-qr.py
]
[tool.uv]
dev-dependencies = [
"pytest>=8",
"pytest-asyncio",
"httpx",
]
Config (~/.keryx/config.yaml)
app:
name: "Keryx"
version: "0.1.0"
supported_app_versions: "^1.0.0"
server:
host: "0.0.0.0"
port: 8643
api_key: "keryx-bff-change-me"
hermes:
api_url: "http://127.0.0.1:8642"
api_key: "hermes-api-server-key"
workspace_path: "/home/david/workspace"
bff:
staging_dir: "/tmp/keryx"
db_path: "/home/david/.keryx/keryx.db"
max_upload_size_mb: 50
staging_cleanup_days: 30
BFF SQLite DB (keryx.db)
-- Minimal. Only for push tokens (post-MVP).
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS push_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_token TEXT NOT NULL,
device_name TEXT,
created_at REAL NOT NULL,
last_seen_at REAL NOT NULL
);
GraphQL Schema (Strawberry)
type Query {
appData: AppData!
page(id: ID!, sessionId: ID): Page!
session(id: ID!): SessionInfo
}
type AppData {
app: AppInfo!
navigation: Navigation!
defaults: Defaults!
features: FeatureFlags!
}
type AppInfo {
name: String!
version: String!
minSupportedVersion: String!
supportedVersions: String!
forceUpdateUrl: String
}
type Navigation {
primaryTab: Tab!
tabs: [Tab!]!
}
type Tab {
id: String!
icon: String!
label: String!
link: String!
}
type Page {
id: String!
header: Header
emptyState: EmptyState
items: [FeedComponent!]!
footer: Footer # only compose_bar or null
}
type Header {
title: String
subtitle: String
actions: [LinkAction!]
}
type EmptyState {
icon: String
title: String!
subtitle: String
action: LinkAction
}
union FeedComponent = SessionCard | MessageBubble | ThinkingBlock
| ToolCallCard | FileRow | CodeBlock
| Divider | ComposeBar
union Footer = ComposeBarLoader
type SessionCard {
id: String!
title: String
modelBadge: String
messageCount: Int!
timestamp: String!
preview: String
actions: [Action!]!
}
type MessageBubble {
id: String!
role: MessageRole!
text: String
streaming: Boolean!
timestamp: Float
attachments: [Attachment!]
}
# ... etc for each component type
Note: No cursor or limit parameters — all lists load in full.
QR Code Generation (scripts/generate-setup-qr.py)
#!/usr/bin/env python3
"""Generate a QR code for Keryx setup.
Reads the BFF config and outputs a QR code PNG that the iOS app
can scan from the SetupView to auto-configure the connection.
Usage:
python scripts/generate-setup-qr.py [--output setup.png]
"""
import json, qrcode, yaml
from pathlib import Path
config_path = Path.home() / ".keryx" / "config.yaml"
config = yaml.safe_load(config_path.read_text())
payload = {
"v": 1,
"url": f"https://{config['server']['host']}:{config['server']['port']}",
"key": config["server"]["api_key"],
}
img = qrcode.make(json.dumps(payload))
img.save("keryx-setup.png")
print("QR code saved to keryx-setup.png")
Build Phases
| Phase | Modules | Milestone |
|---|---|---|
| 1. Core | config.py, auth.py, lifespan.py, server.py |
BFF starts, responds to health |
| 2. app_data | app_data.py route + graphql.py Query |
iOS app fetches startup config |
| 3. GraphQL pages | page_builder.py, component models, sessions |
Sessions list renders via GraphQL |
| 4. SSE streaming | chat_stream.py, hermes_client.py |
Chat works with streaming components |
| 5. File ops | attach.py, staging.py, workspace |
File upload + workspace browsing |
| 6. Polish | Error handling, logging, tests, systemd, QR script | Production-ready |
5. API Contract
GET /v1/ios/app_data
Called on every app launch and foreground. Returns the full startup config.
POST /v1/ios/chat/{session_id}/stream
Sends a message and returns an SSE stream of feed component events.
Request:
{
"message": "What files are in my project?",
"model": "deepseek-v4-flash",
"attachments": [
{"staging_path": "/tmp/keryx/abc123/report.pdf", "name": "report.pdf"}
]
}
SSE Response (stream):
event: component
data: {"type": "message_bubble", "id": "msg_1", "data": {"role": "user", "text": "What files are in my project?"}}
event: component
data: {"type": "tool_call_card", "id": "tc_1", "data": {"tool": "terminal", "command": "ls -la", "status": "running"}, "streaming": true}
event: component_patch
data: {"id": "tc_1", "data": {"status": "completed", "output": "src/\ntests/\nREADME.md"}}
event: component
data: {"type": "message_bubble", "id": "msg_2", "data": {"role": "assistant", "text": "", "code_blocks": []}, "streaming": true}
event: chunk
data: {"component_id": "msg_2", "text": "Your project"}
event: chunk
data: {"component_id": "msg_2", "text": " contains a FastAPI"}
event: chunk
data: {"component_id": "msg_2", "text": " server."}
event: component_complete
data: {"id": "msg_2", "usage": {"input_tokens": 16267, "output_tokens": 20}}
event: done
data: {}
POST /v1/ios/chat/{session_id}/attach
Upload a file for the chat session.
Request: multipart/form-data — field file
Response:
{
"staging_path": "/tmp/keryx/abc123/report.pdf",
"name": "report.pdf",
"size": 245760,
"mime_type": "application/pdf"
}
6. Data Storage
iOS Device (Keychain)
| Item | Key |
|---|---|
| BFF URL | keryx_bff_url |
| API Key | keryx_api_key |
iOS Device (UserDefaults)
| Key | Type | Default |
|---|---|---|
last_session_id |
String | nil |
last_active_model |
String | nil (use BFF default) |
BFF SQLite (keryx.db)
Just push_tokens (post-MVP).
7. Security
| Concern | Measure |
|---|---|
| Auth in transit | HTTPS required. HTTP returns 426 Upgrade Required. |
| Auth at rest | API key in iOS Keychain (Secure Enclave) |
| BFF auth | Bearer token middleware on all endpoints |
| File upload safety | MIME validation, 50 MB cap, .. path traversal blocked |
| Workspace safety | .. / absolute paths blocked. Reads under configured root only |
| Hermes API key | Stored in BFF config, never exposed to iOS app |
| GraphQL depth limiting | Max query depth enforced (default: 5 levels) |
8. Deployment
Prerequisites
- Hermes Agent with API Server enabled (
API_SERVER_ENABLED=true) - Python 3.12+
uv
Installation
# Create project
mkdir -p ~/apps/keryx-bff && cd ~/apps/keryx-bff
# Install deps
uv sync
# Create config
mkdir -p ~/.keryx
# ... write config.yaml ...
# Create staging dir
mkdir -p /tmp/keryx
# Generate QR code (optional — for iOS app setup)
python scripts/generate-setup-qr.py
systemd Service (~/.config/systemd/user/keryx-bff.service)
[Unit]
Description=Keryx BFF – Hermes iOS/iPad Backend For Frontend
After=hermes-gateway.service
BindsTo=hermes-gateway.service
[Service]
Type=simple
ExecStart=%h/apps/keryx-bff/.venv/bin/uvicorn src.server:app --host 0.0.0.0 --port 8643
WorkingDirectory=%h/apps/keryx-bff
Restart=on-failure
RestartSec=5
Environment=KERYX_CONFIG=%h/.keryx/config.yaml
[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now keryx-bff
9. MVP Feature Matrix
BFF (Python / FastAPI)
| Feature | Status |
|---|---|
GET /v1/ios/app_data on startup/foreground |
✅ |
GraphQL Query.appData |
✅ |
GraphQL Query.page for sessions (full list, no pagination) |
✅ |
GraphQL Query.page for workspace (full tree, no pagination) |
✅ |
GraphQL Query.page for chat (historical messages) |
✅ |
SSE streaming via POST /v1/ios/chat/{id}/stream |
✅ |
SSE events: component, chunk, component_patch, component_complete, done |
✅ |
Feed component: session_card |
✅ |
Feed component: message_bubble |
✅ |
Feed component: thinking_block |
✅ |
Feed component: tool_call_card |
✅ |
Feed component: file_row |
✅ |
Feed component: code_block |
✅ |
Feed component: divider |
✅ |
Feed component: compose_bar |
✅ |
Footer type: compose_bar |
✅ |
| File attach upload (multipart, staging, MIME validation, 50 MB cap) | ✅ |
| Workspace file tree (full, no pagination) | ✅ |
Workspace file read (.. traversal blocked) |
✅ |
| File staging 30-day cleanup (lifespan scheduled task) | ✅ |
| Bearer auth middleware | ✅ |
| Hermes API health check on startup | ✅ |
scripts/generate-setup-qr.py |
✅ |
| loguru logging | ✅ |
| uv dependency management | ✅ |
| systemd service deployment | ✅ |
| Tests: health, app_data, chat stream, attach, workspace, GraphQL | ✅ |
iOS App (Swift / TCA)
| Feature | Status |
|---|---|
| TCA architecture with feature-scoped reducers | ✅ |
| Modular project structure (Shared, Sessions, Chat, Workspace, Settings) | ✅ |
| DTO → Mapper → Canonical pipeline per module | ✅ |
Per-screen ForEach rendering (no shared FeedView) |
✅ |
| Inline SSE parser (~50 lines) | ✅ |
SSE → TCA via Effect.run + for try await |
✅ |
keryx:// deep link routing through AppReducer |
✅ |
| Connection setup screen (native SwiftUI, URL + API key) | ✅ |
| QR code scanner (VisionKit DataScannerViewController) | ✅ |
| Keychain credential storage | ✅ |
| Bearer token injection on all requests | ✅ |
| Sessions list (fetch full, render session_cards) | ✅ |
| Chat view: send message, receive SSE stream, render feed | ✅ |
| Chat view: append component, chunk text, patch metadata, complete | ✅ |
Chat view: compose_bar in footer |
✅ |
| File attach (picker + upload) | ✅ |
| iPad drag & drop on compose area | ✅ |
| Voice input (on-device AVSpeechRecognizer) | ✅ |
| Model selector in chat (list from BFF) | ✅ |
| Workspace file browser (full tree, tap to preview) | ✅ |
| NavigationSplitView (iPad multi-column, iPhone push) | ✅ |
| Pull-to-refresh sessions list | ✅ |
| Swipe to delete sessions | ✅ |
10. Post-MVP Roadmap
- Push notifications (APNs)
- Multiple Hermes server profiles
- Message search within sessions
- Message editing / deletion
- Image preview inline in chat
- Offline message queue
- Dark mode testing
- Share extension (share URLs/files from other apps)
- Keyboard shortcuts (iPad)
- Multi-window (Stage Manager)
- Local model fallback (on-device ML)
- External deep link mapping (e.g. from notifications)