Skip to main content
This guide walks through the patterns for building a real AI agent — multi-turn conversation, function calling with tool dispatch, multimodal inputs, and a complete view-model wiring. The cross-platform pages cover individual APIs in depth — start here for the full picture, then drill into the dedicated references when you need details.

Architecture

The ModelRunner owns native memory; the Conversation holds chat history; the MessageResponse stream delivers incremental output. Same shape on every platform.

The generation loop

Every agent has the same shape: send a ChatMessage, iterate the response stream, dispatch each variant. Use the language’s exhaustive switch — onEnum(of:) (Swift) or is checks against the sealed interface (Kotlin) — so the compiler errors if a new MessageResponse case is added.

Multi-turn with tool calls

The defining feature of an agent: the model emits FunctionCalls, you execute the tool, append the result as a tool-role message, and continue. The same pattern works on every platform.
Define runtimeDispatch(_:) as your tool-call → result router: validate arguments, call the underlying implementation, JSON-encode the result. Register the corresponding LeapFunction definitions on the conversation before you start the loop — see Function Calling.

Multimodal inputs

Multimodality is model-specific. Most multimodal models ship as text + one other modality (vision OR audio), not both. Send image parts (Swift ChatMessageContent.fromJPEGData(_:) / Kotlin ImageUtils.fromBitmap(...)) only to a vision-capable model, and audio parts (Swift ChatMessageContent.fromWAVData(_:) / Kotlin ChatMessageContent.Audio(...)) only to an audio-capable model. Verify on the model’s Hugging Face card before wiring up the input.
See Messages & Content for audio format requirements (WAV, mono, 16 kHz recommended) and helpers for recording from the microphone.

Complete view-model example

A ChatViewModel that loads the model, registers a tool, drives generation, and exposes streaming text to the UI.

Pitfalls and best practices

  • Always handle every MessageResponse case. Even if you only care about .chunk and .complete, give .functionCalls, .audioSample, and .reasoningChunk explicit (empty) branches — otherwise an exhaustive switch will fail to compile when a new variant is added.
  • Cancel before re-issuing. Don’t start a second generateResponse(...) while one is in flight. Either cancel the previous Task / Job, or check conversation.isGenerating first.
  • Don’t runBlocking in production paths. It’s fine in onCleared() for guaranteed cleanup (because viewModelScope is already cancelled at that point). Anywhere else, it freezes the calling thread.
  • Use cacheDir (Android) / cachesDirectory (iOS) for KV-cache reuse paths. They’re regenerable — letting the OS reclaim them on storage pressure is the right semantics. See Model Loading → KV cache reuse.
  • Validate tool-call arguments before dispatching. The arguments: Map<String, Any?> (Kotlin) / [String: Any?] (Swift) shape is unsafe by design — defensively coerce types and apply business-level invariants.
  • Match the model’s recommended sampling parameters. The LEAP bundle manifest (sampling_parameters under generation_time_parameters in each <Quant>.json on LiquidAI/LeapBundles) carries defaults tuned per checkpoint for the llama.cpp engine the SDK runs. Overriding temperature and friends often hurts quality more than it helps — start from the manifest values rather than the HF model card defaults (the two can differ).

Platform-specific concerns

  • iOS deployment target: 17.0+ · macOS: 15.0+
  • Xcode 16.0+, Swift 6.0
  • Run model loads inside a Task from a @MainActor view model. ModelDownloader background downloads (via URLSessionConfiguration.background(withIdentifier:)) survive app suspension; see Model Loading.
  • The voice widget exists on UIKit and AppKit — see Voice Assistant Widget.

Troubleshooting

For deeper failure-mode coverage, see Utilities → Errors.