A native iOS app for managing and interacting with Fly.io Sprites — stateful sandbox VMs with persistent filesystems, checkpoint/restore, and HTTP access. Wisp provides a chat-based interface to run Claude Code on remote Sprites from your phone.
- Language: Swift 6
- UI: SwiftUI, iOS 17+ minimum
- State:
@Observableview models (Observation framework) - Navigation:
NavigationStackwithNavigationPath - Persistence: SwiftData for local state (sessions, linked repos, preferences)
- Secrets: iOS Keychain for tokens (Sprites API, Claude Code OAuth, GitHub)
- Networking:
URLSessionfor REST,URLSessionWebSocketTaskfor WebSocket — no third-party networking dependencies - Markdown: MarkdownUI (SPM) for rendering Claude responses with syntax highlighting
- JSON: Custom
JSONValueenum for arbitrary JSON decoding (no AnyCodable dependency) - No third-party dependencies except MarkdownUI (and SwiftTerm in Phase 3)
Wisp/
├── App/ # App entry point, root navigation
├── Models/ # SwiftData models + Codable API types
├── Services/ # API clients, Keychain, networking
│ ├── SpritesAPIClient # REST + WebSocket for Sprites API
│ ├── GitHubClient # GitHub OAuth device flow + API
│ ├── KeychainService # Token storage wrapper
│ └── ClaudeStreamParser # NDJSON binary stream parser
├── ViewModels/ # @Observable view models per feature
├── Views/ # SwiftUI views organised by feature
│ ├── Auth/
│ ├── Dashboard/
│ ├── SpriteDetail/
│ │ ├── Overview/
│ │ ├── Chat/
│ │ └── Checkpoints/
│ └── Settings/
└── Utilities/ # Extensions, helpers, JSONValue
- One
@Observableview model per feature screen - Shared
SpritesAPIClientsingleton injected via SwiftUI environment - View models own async tasks; views are purely declarative
- Use Swift concurrency (
async/await,AsyncThrowingStream) throughout — no Combine - Errors surfaced as user-facing alerts via a shared error handling pattern
- Base URL:
https://api.sprites.dev - Auth:
Authorization: Bearer {sprites_token}header on all requests - REST: JSON request/response via
URLSession - WebSocket exec:
wss://api.sprites.dev/v1/sprites/{name}/exec?cmd=...&env=...cmdandenvare repeatable query params- Non-TTY mode uses binary protocol: frames prefixed with stream ID byte (0=stdin, 1=stdout, 2=stderr, 3=exit, 4=stdin_eof)
- Claude Code NDJSON arrives as raw bytes on stdout (stream ID 1); parse line-by-line
# First message — cd into project dir first
cd /home/sprite/project && claude -p --verbose --output-format stream-json --dangerously-skip-permissions "prompt"
# Follow-up messages — resume session
cd /home/sprite/project && claude -p --verbose --output-format stream-json --dangerously-skip-permissions --resume SESSION_ID "prompt"Environment variables passed via ?env=CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
Each stdout line is a JSON object with a type field:
system— containssession_id, model, tools, cwd. Store session_id for--resume.assistant— containsmessage.content[]which is either text blocks or tool_use blocksuser— tool results (stdout/stderr for Bash, file content for Read/Write, etc.)result— final event withsession_id,duration_ms,num_turns, success/error
Phase 1 scope — build only these features:
- Token auth (Sprites + Claude Code) with Keychain storage
- Sprites list (dashboard) with create/delete
- Sprite detail overview (metadata, status, URL)
- Chat interface (core feature):
claude -pvia WebSocket exec with streaming NDJSON- Render assistant text as chat bubbles with markdown
- Render tool use as collapsible action cards (Bash, Write, Read, Edit, Glob, Grep)
- Session continuity via
--resume - Interrupt (kill exec session)
- Checkpoint list, create, restore
Not in Phase 1: GitHub integration, file browser, web view, terminal, services, network policy, settings screen, background notifications.
- Always add a
#Previewmacro at the bottom of every SwiftUI view file, wrapping inNavigationStackwhere needed and injecting any required environment objects - Always use idiomatic iOS UI/UX patterns — follow Apple's Human Interface Guidelines and standard platform conventions (e.g. tap-to-copy instead of copy buttons, swipe actions, pull-to-refresh, confirmation sheets for destructive actions)
- Use SF Symbols for icons throughout
- System colors and standard iOS chrome — no custom design system
- Sprite status colors: running = green, warm = amber/orange, cold = blue
- Chat bubbles: user messages right-aligned, assistant messages left-aligned
- Tool use cards are collapsible/expandable inline elements between chat bubbles
- Show loading states for Sprite wake-up ("Waking Sprite..." for cold starts, ~1s)
- Destructive actions (delete Sprite, restore checkpoint) always require confirmation dialogs
The app runs on iPhone, iPad, and Mac (Designed for iPad). Keep all three in mind:
- Navigation:
DashboardViewusesNavigationSplitView;List(selection:)+.tag()drives sidebar selection and push navigation on iPhone. Do not remove theselectionbinding — iPhone relies on it for implicit navigation links. - Size class: Use
@Environment(\.horizontalSizeClass)to branch between compact (iPhone) and regular (iPad/Mac) layouts where needed. - Mac detection: The app may run on Mac as a Catalyst build or as a "Designed for iPad" app — handle both.
#if targetEnvironment(macCatalyst)is true for Catalyst;ProcessInfo.processInfo.isiOSAppOnMacis true for "Designed for iPad" on Mac. A safeisRunningOnMachelper should returntruein either case by combining the compile-time and runtime checks. - Content width: Wide screens benefit from a max-width cap on content-heavy views (Overview, Checkpoints, Auth). Use
HStackspacers +.frame(maxWidth:)— do not usecontainerRelativeFrameinsideNavigationSplitViewdetail columns, as it measures the wrong container and compresses the sidebar. - Swipe actions and context menus: Always implement both. Swipe actions are the primary interaction on iPhone/iPad; context menus (long-press on iOS, right-click on Mac) must cover the same set of actions so nothing is inaccessible on Mac. The two should be kept in sync whenever actions are added or removed. Do not use
.disabled()on swipe action buttons — it has no visual effect on iOS. Instead, conditionally show/hide the button. - View model properties: Use
private(set)for properties that views only need to read, not set directly. - Tokens are org-scoped; org slug is embedded in the token string (e.g.
my-org/1290577/...) - Working directory convention:
/home/sprite/projectfor new Sprites,/home/sprite/{repo}for cloned repos - Run
mkdir -p /home/sprite/projecton first chat message if no project dir exists - Settings captions: when a setting needs an explanation, wrap the control and caption text together in a
VStack(alignment: .leading, spacing: 8)inside theSection, with the caption styled.font(.subheadline).foregroundStyle(.secondary).
Two sheet patterns in use — match the right one:
- Form sheets (data entry, e.g. New Sprite, New Checkpoint):
NavigationStack>Form>.navigationTitle+.navigationBarTitleDisplayMode(.inline)+.toolbarwith text Cancel / action-name buttons (e.g. "Cancel" / "Create"). Keep text labels — they're more informative than icons for actions with consequences. - Utility/panel sheets (e.g. Quick Actions):
NavigationStack> custom content >.navigationBarTitleDisplayMode(.inline)+Image(systemName: "xmark")in.cancellationAction. For tabs, use a segmentedPickerinToolbarItem(placement: .principal)+Group { switch selectedTab }for content — do not useTabView. TabView consumes bottom bar space, breaks transparent backgrounds, and its swipe gesture conflicts with any horizontal scroll content inside tabs.
Both sheet types use .background(.clear) on inner content views so the system sheet material shows through. Do not force Color(.systemBackground) on sheet content — let the sheet's natural material handle the background.
Avoid sandwiching a fixed element (e.g. input bar) between two Dividers. A divider above the input bar separating scrollable content from a fixed action area is standard; a second one below it is redundant.
- Run unit tests after making changes to verify nothing is broken
- Add new unit tests when adding or modifying logic (models, parsers, utilities, view models)
- Tests live in
WispTests/and use Swift Testing (import Testing,@Suite,@Test,#expect) - Test target uses
@testable import Wisp
# Build the project
xcodebuild -scheme Wisp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build
# Run tests
xcodebuild -scheme Wisp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' test