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

898 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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`:
```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<SSEEvent> {
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)