keryx-bff/DESIGN.md
2026-07-05 11:10:11 +10:00

30 KiB
Raw Blame History

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

  1. Overview
  2. Architecture
  3. iOS App
  4. BFF (Backend For Frontend)
  5. API Contract
  6. Data Storage
  7. Security
  8. Deployment
  9. MVP Feature Matrix
  10. 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} where items is 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.

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_datapage(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)