From 5b37b5982de72e1940625a6925d0b581fa678f33 Mon Sep 17 00:00:00 2001 From: David Kong Date: Sun, 5 Jul 2026 11:09:49 +1000 Subject: [PATCH] Initial: keryx-bff project files --- DESIGN.md | 898 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ DOMAIN.md | 630 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 1528 insertions(+) create mode 100644 DESIGN.md create mode 100644 DOMAIN.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..27c21c6 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,898 @@ +# 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) diff --git a/DOMAIN.md b/DOMAIN.md new file mode 100644 index 0000000..f1a154a --- /dev/null +++ b/DOMAIN.md @@ -0,0 +1,630 @@ +# Keryx — Domain Language & Conventions + +**Purpose:** Define every term, convention, and naming rule used across Keryx (iOS app + BFF). The iOS app design, code, UI labels, and deep links must match this document exactly. If a term appears in the UI, a GraphQL query, or a deep link, it is defined here. + +--- + +## Table of Contents + +1. [Core Concepts](#1-core-concepts) +2. [UI Component Taxonomy](#2-ui-component-taxonomy) +3. [Page Structure](#3-page-structure) +4. [Navigation & Deep Links](#4-navigation--deep-links) +5. [SSE Streaming Events](#5-sse-streaming-events) +6. [GraphQL Conventions](#6-graphql-conventions) +7. [UI Labels & Copy](#7-ui-labels--copy) +8. [Iconography](#8-iconography) +9. [State Terminology](#9-state-terminology) +10. [Conventions](#10-conventions) +11. [Appendix: Hermes API Mapping](#11-appendix-hermes-api-mapping) + +--- + +## 1. Core Concepts + +### Keryx +The iOS/iPadOS app. Named after the *keryx* (κήρυξ) — the ancient Greek herald who precedes the main messenger (Hermes). The app is the herald that announces what Hermes has to say. + +### Keryx BFF +The Python FastAPI + Strawberry GraphQL backend that the iOS app communicates with. Owns the app experience — drives content, layout, labels, and navigation. Not a thin proxy. + +### Hermes Agent +The underlying AI agent framework running on the user's server. Keryx never talks to Hermes directly; all communication goes through the Keryx BFF. + +### Session +A single conversation with Hermes. Has an ID, a model, a start time, messages, and token usage. Sessions are created and managed by the Hermes API server. Keryx references them by their Hermes session ID. + +### Conversation +Synonym for **Session** in user-facing contexts. In code and APIs, use `session`. In UI labels, prefer "Conversation" or "Chat". + +### Message +A single turn in a session. Has a role (`user` or `assistant`), content, and a timestamp. Messages are never deleted by Keryx. + +### Component +A single piece of UI rendered inside a page feed. Every component has a `type`, an `id`, `data`, and `actions`. See [UI Component Taxonomy](#2-ui-component-taxonomy) for the full list. + +### Feed +The ordered array of `items` within a `page`. The iOS app renders components in array order, left-to-right, top-to-bottom. + +### Page +A full-screen view presented by the iOS app. Every page has the structure `{header, items[], footer}`. The BFF builds pages; the iOS app renders them. + +### Workspace +A directory on the Hermes server containing project files the user interacts with. Defined by a single root path in the BFF config. Read-only. + +### Deep Link +A `keryx://` URL that the iOS app uses for all navigation and actions. No screen transitions happen without a deep link. See [Navigation & Deep Links](#4-navigation--deep-links). + +--- + +## 2. UI Component Taxonomy + +Every component has this shape: + +```json +{ + "type": "component_type_name", + "id": "unique_identifier", + "data": { ... }, + "actions": [ + {"type": "tap|swipe_left|swipe_right", "label": "Action", "style": "normal|destructive", "link": "keryx://..."} + ], + "streaming": false +} +``` + +`streaming` is `true` when the component is still receiving data via the SSE stream. The iOS app may show a shimmer/typing indicator for streaming components. + +### 2.1 `session_card` + +A session/conversation in the sessions list. + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | String | ✅ | Hermes session ID | +| `title` | String? | ❌ | Session title. `null` if untitled. | +| `model_badge` | String? | ❌ | Model name shown as a badge (e.g. "deepseek-v4-flash") | +| `message_count` | Int | ✅ | Total messages in the session | +| `timestamp` | String | ✅ | Relative time string (e.g. "2m ago", "Yesterday") — generated by BFF | +| `preview` | String? | ❌ | First ~80 chars of the session for preview | + +**Display rules:** +- If `title` is `null`, show the `preview` as the primary text (truncated to 1 line) +- If `title` is set, show `title` as primary, `preview` as secondary +- `model_badge` shown as a rounded pill +- `timestamp` right-aligned +- Swipe left reveals "Delete" (destructive) + +### 2.2 `message_bubble` + +A single message in a conversation. + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | String | ✅ | Message ID from Hermes | +| `role` | Enum | ✅ | `"user"` or `"assistant"` | +| `text` | String? | ❌ | Message content. May be empty during streaming. | +| `streaming` | Bool | ✅ | `true` while assistant is still generating | +| `timestamp` | Float? | ❌ | Unix timestamp | +| `model` | String? | ❌ | Model used for this response (assistant only) | +| `attachments` | [Attachment]? | ❌ | Files attached to this message | + +**`Attachment`:** +```json +{ + "name": "report.pdf", + "size": 245760, + "mime_type": "application/pdf" +} +``` + +**Display rules:** +- User messages: right-aligned, tinted background +- Assistant messages: left-aligned, default background +- `model` shown as a small label above assistant messages +- Streaming messages show a blinking cursor at end of text + +### 2.3 `thinking_block` + +Collapsible reasoning from the assistant. + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | String | ✅ | Unique ID | +| `text` | String | ✅ | Reasoning content | +| `collapsed_by_default` | Bool | ❌ | Default: `true` | + +**Display rules:** +- Tappable header "Thinking..." +- Expand/collapse animation +- Grey/dim text when collapsed +- Header shows token count if available + +### 2.4 `tool_call_card` + +A tool invocation by Hermes (terminal, web search, file read, etc.). + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | String | ✅ | Unique ID | +| `tool` | String | ✅ | Tool name (e.g. "terminal", "web_search") | +| `command` | String? | ❌ | The command/query | +| `status` | Enum | ✅ | `"running"`, `"completed"`, `"failed"` | +| `output` | String? | ❌ | Tool output (truncated) | +| `duration_ms` | Int? | ❌ | Execution time | + +**Display rules:** +- Show tool icon + name as a compact card +- Show command as monospaced text +- Status indicator: spinner (running), checkmark (completed), X (failed) +- Tappable to expand full output (post-MVP) + +### 2.5 `file_row` + +A file or directory in the workspace browser. + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | String | ✅ | File path (used as identifier) | +| `name` | String | ✅ | File/directory name | +| `is_directory` | Bool | ✅ | True if directory | +| `size` | Int? | ❌ | File size in bytes (null for directories) | +| `mime_type` | String? | ❌ | MIME type for files | +| `modified_at` | String? | ❌ | Relative time string | +| `children` | [FileRow]? | ❌ | When depth > 0, nested files | + +**Display rules:** +- Directory icon (folder) or file icon based on type +- Tap on directory → navigates into it +- Tap on file → opens preview (post-MVP: in-app viewer) +- Indentation for nested children + +### 2.6 `code_block` + +A rendered code snippet inside a message_bubble. When Hermes returns markdown with code fences, the BFF extracts them into standalone components. + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | String | ✅ | Unique ID | +| `language` | String? | ❌ | Language identifier (e.g. "python", "json") | +| `code` | String | ✅ | The code content | +| `streaming` | Bool | ✅ | `true` while still receiving code | + +**Display rules:** +- Monospaced font, syntax highlighting (SF Mono) +- Language badge in top-right +- Copy button in top-right (copy to clipboard) +- Scrollable horizontal for long lines + +### 2.7 `divider` + +A visual separator. + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | String | ✅ | Unique ID | +| `label` | String? | ❌ | Optional text in the divider | + +**Display rules:** +- Thin horizontal line +- If `label` present, text centered in the line + +### 2.8 `compose_bar` + +The chat input area. Only appears in the `footer` of a chat page, never in `items`. + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | String | ✅ | Always `"compose"` | +| `placeholder` | String | ✅ | Placeholder text (e.g. "Ask something...") | +| `actions` | ComposeActions | ✅ | Deep links for send, attach, voice | +| `disabled` | Bool | ✅ | `true` while a message is being sent/streaming | + +**`ComposeActions`:** +```json +{ + "send": "keryx://chat/{id}/send", + "attach": "keryx://chat/{id}/attach", + "voice": "keryx://chat/{id}/voice" +} +``` + +**Display rules:** +- Fixed at bottom of screen +- Text field + send button + attach button + voice button +- Disabled state: greyed out, non-interactive +- Shows attachment chip when a file is staged + +--- + +## 3. Page Structure + +Every page response has this structure: + +```json +{ + "page": { + "id": "string", + "header": { ... } | null, + "items": [ ... ], + "footer": { ... } | null + } +} +``` + +### Header + +```json +{ + "title": "Conversations", + "subtitle": "82 total", + "actions": [ + {"icon": "plus", "label": "New Chat", "link": "keryx://new-chat"} + ] +} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `title` | String? | ❌ | Main header text | +| `subtitle` | String? | ❌ | Smaller text below title | +| `actions` | [LinkAction]? | ❌ | Right-side action buttons | + +### Footer + +The footer is either `null` (no footer) or contains the compose bar for chat pages. There is no pagination — all lists load in full. + +```json +// Chat page footer +{ + "type": "compose_bar", + "placeholder": "Ask something...", + "actions": { + "send": "keryx://chat/{id}/send", + "attach": "keryx://chat/{id}/attach", + "voice": "keryx://chat/{id}/voice" + }, + "disabled": false +} +``` + +| Type | Description | +|---|---| +| `compose_bar` | Chat input area (chat page only) | +| `null` | No footer (settings page, workspace, sessions list) | + +### Empty State + +When `items` is empty, the BFF may include an `empty_state` field at the page level (not inside `header`): + +```json +{ + "page": { + "id": "sessions", + "header": {"title": "Conversations"}, + "empty_state": { + "icon": "message", + "title": "No conversations yet", + "subtitle": "Start a new chat to begin", + "action": {"label": "Start Chat", "link": "keryx://new-chat"} + }, + "items": [], + "footer": null + } +} +``` + +The iOS app shows the empty state view when `items.length === 0` and `empty_state` is present. + +--- + +## 4. Navigation & Deep Links + +All navigation is driven by `keryx://` deep links. The iOS app registers the `keryx` URL scheme and routes links to screens. No screen transitions are hardcoded. + +### Link Format + +``` +keryx:///?query=val +``` + +### Deep Link Registry + +| Link | Action | Description | +|---|---|---| +| `keryx://sessions` | Present | Sessions list page | +| `keryx://new-chat` | Present | Create new session → navigate to chat | +| `keryx://chat/{session_id}` | Present | Open chat view for an existing session | +| `keryx://chat/{session_id}/send` | Action | Send message (POST) — triggered by compose bar | +| `keryx://chat/{session_id}/attach` | Action | Attach file (POST) — triggered by compose bar | +| `keryx://chat/{session_id}/voice` | Action | Start voice input — triggered by compose bar | +| `keryx://delete/{session_id}` | Action | Delete a session | +| `keryx://rename/{session_id}` | Action | Rename a session | +| `keryx://workspace` | Present | Workspace file browser page | +| `keryx://workspace/{path}` | Present | Navigate into a directory in workspace | +| `keryx://workspace/read/{path}` | Present | Preview a workspace file | +| `keryx://settings` | Present | Settings page | +| `keryx://release-notes` | Present | Release notes / changelog | +| `keryx://{anything}` | Fallback | Open in system browser or show error | + +### Navigation Rules + +1. **Present** links push a new page onto the navigation stack (iPhone) or select a column (iPad) +2. **Action** links trigger a side effect (POST, DELETE) and are handled without navigation +3. The app never constructs URLs dynamically — all links come from BFF responses +4. Unknown link schemes are silently dropped (app doesn't crash) + +--- + +## 5. SSE Streaming Events + +The SSE protocol for `POST /v1/ios/chat/{session_id}/stream`. + +### Event: `component` + +Sent when a new UI component should be appended to the feed. + +```json +event: component +data: {"type": "message_bubble", "id": "msg_1", "data": {"role": "user", "text": "Hello"}} +``` + +**iOS action:** Append a new `FeedComponent` to `items[]`. Set `streaming: true` if the component may receive further updates. + +### Event: `chunk` + +Sent when text content should be appended to an existing component. + +```json +event: chunk +data: {"component_id": "msg_2", "text": "def fibonacci"} +``` + +**iOS action:** Find component by `component_id` in `items[]`. Append `text` to its `data.text` field. Re-render in place. + +### Event: `component_patch` + +Sent when a component's metadata should be updated (e.g. tool status change). + +```json +event: component_patch +data: {"id": "tc_1", "data": {"status": "completed", "output": "src/\ntests/"}} +``` + +**iOS action:** Find component by `id`. Deep-merge `data` into the component's existing `data`. Fields not in `data` are preserved. + +### Event: `component_complete` + +Sent when a component is finalised (no more updates). + +```json +event: component_complete +data: {"id": "msg_2", "usage": {"input_tokens": 16267, "output_tokens": 20}} +``` + +**iOS action:** Find component by `id`. Set `streaming: false`. Merge any additional `data` fields (e.g. `usage`). + +### Event: `done` + +Sent when the stream is complete. + +```json +event: done +data: {} +``` + +**iOS action:** Close SSE connection. Re-enable the send button. The `compose_bar` in the footer should set `disabled: false`. + +### Error Handling + +If the BFF encounters an error mid-stream: + +```json +event: error +data: {"code": "hermes_unreachable", "message": "Hermes API server is not responding"} +``` + +**iOS action:** Show inline error banner at the bottom of the feed. The stream is not closed — the user can retry. + +--- + +## 6. GraphQL Conventions + +### Naming + +| Convention | Rule | Example | +|---|---|---| +| Queries | `camelCase` | `appData`, `sessionList` | +| Mutations | `camelCase`, verb-first | `sendMessage`, `deleteSession` | +| Types | `PascalCase` | `AppData`, `SessionCard` | +| Fields | `camelCase` | `modelBadge`, `messageCount` | +| Enums | `PascalCase` | `MessageRole`, `ToolStatus` | +| Enum values | `SCREAMING_SNAKE_CASE` | `USER`, `ASSISTANT`, `COMPLETED` | + +### Union Types for Feed Components + +The `FeedComponent` union type uses Strawberry's `@strawberry.type` with `@strawberry.union`. The iOS app receives the `__typename` field to determine which fragment to use: + +```graphql +query SessionList { + page(id: "sessions") { + items { + __typename + ... on SessionCard { + id title modelBadge messageCount timestamp preview + } + ... on Divider { + id label + } + } + } +} +``` + +### Pagination + +No pagination. All lists load in full — sessions, workspace trees, and chat message histories. The page query returns every item at once. + +--- + +## 7. UI Labels & Copy + +### Standard Labels + +| Context | Label | Notes | +|---|---|---| +| App display name | **Keryx** | Not "Keryx iOS" or "Keryx Client" | +| Tab: sessions | **Chats** | User-friendly term | +| Tab: workspace | **Files** | | +| Tab: settings | **Settings** | | +| New chat button | **New Chat** | | +| Delete action | **Delete** | Destructive | +| Rename action | **Rename** | | +| Send button | *(None — icon only)* | Paper plane icon | +| Attach button | *(None — icon only)* | Paperclip icon | +| Voice button | *(None — icon only)* | Microphone icon | +| Empty sessions | **No conversations yet** | | +| Empty workspace | **No files yet** | | +| Model selector | *(None — label varies)* | e.g. "DeepSeek V4 Flash" | +| Streaming indicator | *(None — visual only)* | Blinking cursor / shimmer | +| Connection: loading | **Connecting...** | | +| Connection: error | **Could not connect** | With retry button | +| Session: untitled | *(Show first 80 chars of preview)* | Not "Untitled" | +| Delete confirmation | **Delete conversation?** | Action sheet | + +### Tone & Voice + +- **Friendly but direct** — the app is a tool, not a companion +- **Minimal labels** — every visible word serves a purpose +- **No "wizard" or "magic" language** — Hermes is an agent, not a magician +- **Use "Chat", not "Chat with Hermes" or "Conversation with AI"** +- **Use "Files", not "Workspace Files" or "Project Files"** + +--- + +## 8. Iconography + +### SF Symbols Mappings + +| Context | SF Symbol Name | Notes | +|---|---|---| +| Tab: Chats | `message.fill` | | +| Tab: Files | `folder.fill` | | +| Tab: Settings | `gearshape.fill` | | +| New chat | `plus` | | +| Send | `paperplane.fill` | | +| Attach | `paperclip` | | +| Voice | `mic.fill` | | +| Delete | `trash.fill` | | +| Rename | `pencil` | | +| Copy code | `doc.on.doc` | | +| Expand | `chevron.down` | | +| Collapse | `chevron.right` | | +| Directory | `folder` | Non-filled for file rows | +| File (generic) | `doc` | | +| File (code) | `doc.text` | | +| File (image) | `photo` | | +| File (PDF) | `doc.richtext` | | +| Empty state | `tray` | | +| Success | `checkmark.circle.fill` | Green | +| Error | `xmark.circle.fill` | Red | +| Pending | `ellipsis` | Animated | + +--- + +## 9. State Terminology + +### Connection States + +| State | BFF `app_data` field | iOS App Behaviour | +|---|---|---| +| **Connected** | `server.hermes_connected: true` | Normal operation | +| **Hermes Unreachable** | `server.hermes_connected: false` | Show banner "Hermes is not responding. Responses may be delayed." | +| **BFF Error** | 500 / timeout | Show error screen with retry | +| **Version Mismatch** | `app.force_update_url` not null | Show force-update screen until app is updated | + +### Session States + +| State | BFF Signal | iOS App Behaviour | +|---|---|---| +| **Loading** | N/A (page fetch in progress) | Shimmer / skeleton | +| **Loaded** | Items array populated | Render feed | +| **Empty** | `empty_state` present, `items` empty | Render empty state | +| **Error** | Error in response | Show error with retry button | + +### Component States + +| State | `streaming` field | iOS App Behaviour | +|---|---|---| +| **Static** | `false` | Render normally | +| **Streaming** | `true` | Show shimmer/blinking cursor | +| **Updating** | `true` (with `chunk` events arriving) | Append text in place | +| **Complete** | `false` (after `component_complete`) | Remove shimmer, show final content | + +--- + +## 10. Conventions + +### Naming + +| Domain | Convention | Example | +|---|---|---| +| Files | `snake_case` | `session_card`, `keryx_bff` | +| GraphQL fields | `camelCase` | `messageCount`, `createdAt` | +| Component types | `snake_case` | `message_bubble`, `tool_call_card` | +| Deep links | `keryx://resource/action` | `keryx://chat/{id}/send` | +| SSE events | `lowercase_snake` | `component_complete`, `component_patch` | +| Config keys | `snake_case` | `staging_cleanup_days` | +| HTTP headers | `Pascal-Case` | `Authorization`, `Content-Type` | +| Enum values (Swift) | `lowerCamelCase` | `.user`, `.assistant` | +| Enum values (Python) | `SCREAMING_SNAKE` | `USER`, `ASSISTANT` | + +### Dates & Times + +- **BFF → iOS:** Relative time strings for display ("2m ago", "Yesterday", "3 weeks ago") +- **BFF → iOS:** Unix timestamps (Float) for sorting +- **BFF generation:** Use user's timezone (configured in BFF, or sent by iOS app in headers) +- **iOS display:** Never show raw timestamps to users + +### Files + +- **File sizes** returned as bytes (Int). iOS converts for display (KB, MB, etc.) +- **MIME types** follow standard IANA values (`application/pdf`, `text/plain`, `image/png`) +- **Workspace paths** are always relative to the configured workspace root. The BFF resolves absolute paths internally. + +### Errors + +| Error Code | HTTP Status | Meaning | +|---|---|---| +| `auth_invalid` | 401 | API key missing or wrong | +| `session_not_found` | 404 | Session ID doesn't exist | +| `file_too_large` | 413 | Upload exceeds `max_upload_size_mb` | +| `unsupported_file_type` | 415 | MIME type not allowed | +| `hermes_unreachable` | 502 | Hermes API server not responding | +| `hermes_error` | 502 | Hermes returned an error | +| `workspace_out_of_bounds` | 403 | Path traversal attempt blocked | +| `rate_limited` | 429 | Too many requests | + +--- + +## 11. Appendix: Hermes API Mapping + +This documents how BFF concepts map to Hermes API Server endpoints and data. + +| Keryx Concept | Hermes API Endpoint | Notes | +|---|---|---| +| Session ID | API Server session ID | Direct 1:1 mapping | +| Create session | `POST /api/sessions` | Returns Hermes session ID | +| Session list | `GET /api/sessions` | BFF transforms to session_card components | +| Session messages | `GET /api/sessions/{id}/messages` | BFF transforms to message_bubble + thinking_block + tool_call_card | +| Delete session | `DELETE /api/sessions/{id}` | | +| Rename session | `PATCH /api/sessions/{id}` | Update `title` field | +| Send message | `POST /api/sessions/{id}/chat/stream` | SSE stream of events | +| Model list | `GET /v1/models` | Proxied through BFF | +| Health check | `GET /v1/health` | | +| File upload | *(Not in Hermes API)* | BFF handles staging | +| Workspace files | *(Not in Hermes API)* | BFF reads filesystem | + +--- + +*This document is the source of truth for all naming, terminology, and conventions used in the Keryx project. If a UI label, GraphQL field, deep link, or code identifier contradicts this document, this document wins.*