# 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](#1-overview) 2. [Architecture](#2-architecture) 3. [iOS App](#3-ios-app) 4. [BFF (Backend For Frontend)](#4-bff-backend-for-frontend) 5. [API Contract](#5-api-contract) 6. [Data Storage](#6-data-storage) 7. [Security](#7-security) 8. [Deployment](#8-deployment) 9. [MVP Feature Matrix](#9-mvp-feature-matrix) 10. [Post-MVP Roadmap](#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`: ```swift @Reducer struct AppReducer { @ObservableState struct State: Equatable { var path = StackState() var sessionsList = SessionsListReducer.State() var settings = SettingsReducer.State() } enum Action { case path(StackActionOf) case sessionsList(SessionsListReducer.Action) case settings(SettingsReducer.Action) case deepLink(URL) } var body: some ReducerOf { 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`: ```swift 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: ```swift 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: ```swift // 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`: ```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 { AsyncStream { continuation in // Split on "\n\n", parse "event:" and "data:" lines // Decode data JSON // continuation.yield(SSEEvent(name: ..., data: ...)) } } } ``` Usage in `ChatReducer`: ```swift 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`) ```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`) ```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`) ```sql -- 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) ```graphql 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`) ```python #!/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:** ```json { "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:** ```json { "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 ```bash # 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`) ```ini [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 ``` ```bash 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)