Feature behavior and SDK surface

August 17, 2026 · View on GitHub

Per-feature detail behind the one-line index in the root AGENTS.md. Read the relevant section before touching that feature's code — several of these encode a fix for a real bug, not just a design choice.

Chat and LLM

LLMViewModel is split across ten files by concern: core state and sendMessage() in LLMViewModel.swift, then +Generation (streaming and non-streaming), +ToolCalling, +ModelManagement, +Analytics, +Events (Combine subscription to RunAnywhere.eventBus), +Documents (RAG-backed attachments), +MessageActions, +Vision, and the shared LLMViewModelTypes. ToolCallingModelPolicy gates tools on context length alone (minimumContextTokens = 1024 against the model's contextLength), not on model identity; its companion ToolCallingExecutionPolicy caps the run at two tool calls and 96 tokens of final response, at temperature 0 with reasoning off.

Flow: input, sendMessage(), prepareMessagesForSending() (creates the user message and an empty assistant message), executeGeneration(), performGeneration(), then the streaming, non-streaming, or tool-calling path, token-by-token message updates, finalizeGeneration(), and persistence to ConversationStore.

Tool calling runs through RunAnywhere.llm.generate with the registry active; the SDK owns the call and execute loop and the format is auto-detected per model.

LoRA lives almost entirely in the SDK. ModelCatalogBootstrap.registerLoraAdapters() seeds the curated catalog as RALoraAdapterCatalogEntry values, mirroring Android's ModelBootstrap.seedLora, and registers each one with RunAnywhere.lora.registerArtifact. From there the app calls RunAnywhere.lora.queryCatalog(_:) (with an RALoraAdapterCatalogQuery), .download(_:artifact:), .importAdapter(from:), .applyCatalogAdapter(_:localPath:scale:), .apply(RALoraApplyRequest) for a raw path, .remove(RALoraRemoveRequest), and .state(). Removal is id-keyed or clearAll; the old path-keyed fallback was deleted. Scale is user-adjustable.

Conversations persist as per-conversation JSON under Documents/Conversations/, attachments under Conversations/Attachments/{conversationID}/. Search covers titles and message content.

Titles are written by whichever model answered, through RunAnywhere.llm, not by a separate FoundationModels.LanguageModelSession. Apple's Foundation Models will not serve two clients against one on-device model: the old app-side title session hung and wedged every subsequent turn behind it with no error and no timeout. Only one title task exists at a time and cancelPendingTitleGeneration() hands the model back the moment the chat claims a new turn.

Analytics: MessageAnalytics per message (time to first token, tokens per second, thinking mode, completion status) and ConversationAnalytics rolled up onto the stored Conversation. ChatDetailsView recomputes its figures from the per-message records rather than reading the rolled-up type, and shows tokens per second, thinking usage, success rate, and average total generation time. Per-message TTFT is rendered in ChatMessageComponents, not in ChatDetailsView.

Thinking mode: models with supportsThinking expose reasoning through the SDK's reasoning options and thinkingText or stream thought events. Commons owns tag parsing and /no_think directives; the app toggles the mode and renders the returned channel in a collapsible section.

Documents attached to a chat go through ChatAttachmentLoader and DocumentService for text extraction, then RunAnywhere.rag.open(embeddingModel:llmModel:) for a RagSession cached on the view model. The cache key is document plus embedding model plus answer model, so a session is reused across turns rather than being strictly one per conversation. There is no separate RAG screen.

Voice agent

RunAnywhere.voice.createSession(stt:llm:tts:) then session.start(), which is the only call that opens the microphone. session.events yields agentStateChanged (with .listening, .thinking, .speaking), speechStarted, speechEnded, userTranscribed, agentResponse, inputSilent, and error. session.interrupt() and session.close() are the other two verbs. The SDK owns the whole audio pipeline including its own VAD. The user loads STT, LLM, and TTS models independently through ModelSelectionSheet.

VoiceAssistantParticleView is a Metal-rendered 2000-particle system: a Fibonacci-lattice sphere that morphs to a ring while listening or speaking, with amplitude from the real microphone level when listening and a simulated sine wave when speaking, and touch scatter decaying at 0.92.

Speech, synthesis, and voice activity

STT has three modes. Batch records audio then calls RunAnywhere.stt.transcribe(.pcm16(buffer, sampleRate: 16_000)). Live yields microphone chunks into RunAnywhere.stt.transcribeStream(_:), which owns segmentation and emits .partial and .final. Hybrid runs on-device first with cloud fallback through the SDK's HybridSTTRouter. Capture is the SDK's AudioCaptureManager driven by the app-local AudioCapturePump; no app-side silence detection exists.

TTS is RunAnywhere.tts.speak(text, options: TtsOptions(speed:)), which synthesizes and plays inside the SDK and hands back a handle. The app awaits handle.waitForPlayout() and interrupts with handle.interrupt(); the whole-engine RunAnywhere.tts.stop() is deprecated and used only as a fallback when no handle exists. Use tts.synthesize(_:) when the Audio buffer is wanted instead of playback.

VAD feeds microphone chunks to RunAnywhere.vad.detectStream(_:), which emits VadEvent values: .speechStarted, .speechEnded, per-chunk .activity(isSpeech, _, _), .failed, and .completed. Framing is the SDK's job. The activity log holds 50 entries.

Voice keyboard (RunAnywhereKeyboard + RunAnywhereActivityExtension)

Cross-process dictation over two IPC channels: App Group UserDefaults (group.com.runanywhere.runanywhereai) for shared state (session state, transcribed text, audio level, heartbeat), and Darwin CFNotificationCenter for zero-latency signals (six names in SharedConstants.DarwinNotifications, in RunAnywhereAI/Shared/). Both channel definitions are shared code, not owned by either extension target.

The keyboard's Run button opens runanywhere://startFlow. The main app activates a session, loads the STT model, starts capture, and posts sessionReady. The user returns to the host app, the keyboard sends startListening, the main app buffers audio, the keyboard sends stopListening, the main app calls RunAnywhere.stt.transcribe(_:), writes the result to shared UserDefaults, and posts transcriptionReady; the keyboard inserts it through textDocumentProxy.insertText().

DictationActivityAttributes.ContentState (Live Activity, in RunAnywhereActivityExtension/) carries phase, elapsed seconds, transcript, and word count for the Dynamic Island and Lock Screen. A one-second heartbeat lets the keyboard detect a main-app crash after a three-second staleness window.

Both extensions are thin: RunAnywhereKeyboard/ is KeyboardViewController + KeyboardView; RunAnywhereActivityExtension/ is the WidgetBundle entry plus the Live Activity view. Neither has its own build/test entry point — scripts/build_and_run_ios_sample.sh and scripts/verify.sh build the whole app target including both extensions.

Vision

Camera and photo-library image understanding, reached from the chat rather than its own tab. AVCaptureSession with BGRA pixel format feeds RunAnywhere.vlm.generateStream(image: .pixelBuffer(frame), prompt:, options:). Live mode captures every 2.5 seconds (autoStreamInterval) and clears on the first token, so an unchanged scene does not repeat itself. Its token cap is autoStreamMaxTokens = 64; the single-shot path has its own, larger singleShotMaxTokens. Pixel conversion belongs to the SDK: pass ImageInput a CVPixelBuffer and do not bridge through CIContext.

Benchmarks

Deterministic tests across LLM, STT, TTS, and VLM, each with a BenchmarkScenarioProvider. BenchmarkRunner orchestrates with cooperative cancellation. Results persist as JSON, capped at 50 runs. BenchmarkExportFormat offers Markdown and JSON; CSV exists only as a writeCSV(run:) file writer with no picker entry. SyntheticInputGenerator produces silent and sine-wave audio (440 Hz at 16 kHz) and solid and gradient 224x224 images. LLM scenarios run at 50, 256, and 512 tokens measuring TTFT and decode speed.

Models

ModelListViewModel is the canonical registry singleton, subscribed to RunAnywhere.eventBus.modelLifecycle for live load and unload state. ModelSelectionSheet is the universal picker, parameterized by ModelSelectionContext: .llm, .stt, .tts, .vad, .voice, .vlm, .ragEmbedding, .ragLLM, .diarization, .segmentation. Custom models arrive through AddModelFromURLView or AddFromHuggingFaceView. ModelRecommendationEngine and ModelCompatibilityLookup drive the recommended set, and HardwareTier scopes it to the device.

Storage

RunAnywhere.models.state() gives used and free bytes; RunAnywhere.models.list(filter: ModelFilter(downloadedOnly: true)) gives the rows, filtered to entries with a real on-disk size so Apple system pseudo-models drop out. Each row reads its own ModelInfo for name, local path, framework, and lastUsedAtUnixMs. Deletion is RunAnywhere.models.delete(id:); cache and temp clearing are RunAnywhere.clearCache() and RunAnywhere.cleanTempFiles(). StorageViewModel surfaces this inside Settings and the models views.

Settings and tools

SettingsViewModel (singleton) owns temperature, max tokens, and system prompt in UserDefaults, and API key and base URL in the Keychain. Temperature, max tokens, and system prompt each save on a Combine debounce(0.5s); the thinking-mode toggle writes through immediately.

ToolSettingsViewModel is a separate singleton and registers tools through RunAnywhere.llm.tools. Six are always available: get_weather (Open-Meteo), get_current_time, calculate (a recursive-descent SafeMathEvaluator), get_device_info, get_battery_level, and the SDK's own web search, added by RunAnywhere.registerWebSearchTool(). Two more are opt-in behind a toggle and a permission prompt: get_calendar_events (CalendarTool) and get_health_data (HealthKitTool, iOS only). registerBuiltInTools() restores the enabled set at launch, because assigning a stored property inside init does not fire didSet.

Markdown rendering

One path, not a detect-and-route chain. MarkdownBlockParser.parse(_:) turns the reply into [MarkdownBlock] (paragraph, heading, list, quote, code, rule) with no SwiftUI involved, and AdaptiveMarkdownText renders one view per block: MarkdownListView, MarkdownQuoteView, MarkdownCodeBlock (syntax-colored header, copy button, monospaced scrollable body), and inline text through MarkdownText in InlineMarkdownRenderer.swift, which uses AttributedString(markdown:) with bold as .semibold, italic as .italic, and inline code monospaced and purple-tinted.

SDK surface used here

Every call goes through the RunAnywhere enum. One namespace per modality; the SDK owns model resolution, loading, downloading, and orchestration behind each verb. What follows is what this app actually calls, not the SDK's full surface, which is larger.

// Core
try RunAnywhere.initialize(apiKey:baseUrl:environment:)   // one call, both phases
RunAnywhere.isReady
RunAnywhere.eventBus.events / .modelLifecycle             // Combine, raw RASDKEvent protos
RunAnywhere.clearCache() / .cleanTempFiles()

// Models
RunAnywhere.models.list(filter:) / .get(id:) / .register(_:) / .refresh()
RunAnywhere.models.download(id:) / .isResumable(...) / .checkCompatibility(...)
RunAnywhere.models.load(id:options:) / .unload(category:) / .delete(id:) / .state()

// Generation
RunAnywhere.llm.generate(...) / .generateStream(...)
RunAnywhere.llm.tools.register(_:executor:) / .list() / .clear()
RunAnywhere.vlm.generate(image:prompt:options:) / .generateStream(...)

// Audio and vision
RunAnywhere.stt.transcribe(_:options:) / .transcribeStream(_:options:)
RunAnywhere.tts.speak(_:options:) / .synthesize(_:options:) / .stop()
RunAnywhere.vad.detectStream(_:options:)
RunAnywhere.diarization.diarize(_:options:)
RunAnywhere.segmentation.segment(_:options:)

// Sessions
let voice = try await RunAnywhere.voice.createSession(stt:llm:tts:)
try voice.start()             // the only thing that opens the microphone
let rag = try await RunAnywhere.rag.open(embeddingModel:llmModel:)

// LoRA
RunAnywhere.lora.registerArtifact(_:artifact:) / .allRegistered() / .queryCatalog(_:)
RunAnywhere.lora.download(_:artifact:) / .importAdapter(from:) / .applyCatalogAdapter(...)
RunAnywhere.lora.apply(RALoraApplyRequest) / .remove(RALoraRemoveRequest) / .state()

The SDK also exposes embeddings, rerank, images, generateStructured, tts.voices(), vad.detect(_:), RunAnywhere.events, .version, .deviceId, and deleteStorage(_:). This app calls none of them. Do not document them here as if it did.

Inputs are AudioInput and ImageInput. The app constructs .pcm16, .uiImage, and .pixelBuffer; the other cases exist but go unused here. Options types carry all-optional fields whose defaults come from the IDL, so the app passes only what it overrides: LlmOptions and TtsOptions in practice.

One-shot verbs throw SDKException. Stream factories are async throws -> AsyncThrowingStream, so they throw on preflight failure and throw into the consumer mid-flight. No result carries a success flag and no error text hides in a payload field. Cancel by cancelling the consuming Task; there are no cancel verbs.

The older flat verbs (loadModel, transcribe, ragQuery) are deprecated forwarders in the SDK. Do not use them here.

RunAnywhere+ExampleShims.swift holds one app-local helper, RunAnywhere.getRegisteredFrameworks() -> [RAInferenceFramework], which composes RunAnywhere.models.list() into a framework filter sorted by descending model count. A new feature needing net-new C bridge code belongs in the SDK; only UI plumbing over existing canonical proto APIs belongs in that file.