Use when building or modifying a Swift app that uses the AgentSquad Swift framework — on-device multi-agent orchestration for iOS 16+ / macOS 14+: orchestrator, agents (Agent, GroundedAgent), classifier routing, LLM clients (OpenAI-compatible), tools (native + MCP), tool UIs/widgets, on-device storage, tracing, and realtime voice — built-in types and custom implementations.
Scanned 8/30/2026
Install to Claude Code
npx -y skills add 2FastLabs/agent-squad --skill swift --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Swift?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/2fastlabs-swift)More formats (shields.io, HTML) on the badges page.
---
name: agent-squad-swift
description: >-
Use when building or modifying a Swift app that uses the AgentSquad Swift framework — on-device
multi-agent orchestration for iOS 16+ / macOS 14+: orchestrator, agents (Agent, GroundedAgent),
classifier routing, LLM clients (OpenAI-compatible), tools (native + MCP), tool UIs/widgets,
on-device storage, tracing, and realtime voice — built-in types and custom implementations.
---
# AgentSquad Swift — assistant guide
Protocol-driven, on-device multi-agent framework (Swift 6.2, iOS 16+ / macOS 14+; persistence via
`FileChatStorage` on iOS 16+, `DeviceChatStorage` on iOS 17+). This is guidance and
a map — **not an API reference**. Read the exact signatures from the source (`swift/Sources/AgentSquad/`)
and the worked recipes from the docs site sources (`docs/src/content/docs/swift/`); this file tells
you *what to use, when, and what to watch out for*.
## When to use what
- **One assistant** → an `Agent` (or `GroundedAgent`) + an `Orchestrator` with no classifier. No routing hop.
- **Several specialists** → multiple agents + an `LLMClassifier`; the orchestrator routes each turn.
- **Answers must not drift from data** (prices, stock, balances) → `GroundedAgent`: a Brain calls
tools, an isolated Presenter speaks only from the curated results (it can be a smaller/local model).
- **Voice** → a `VoiceAssistant` (a peer of the orchestrator, not an agent): `OpenAIVoiceAssistant`
(single LLM + tools, speaks directly — the spoken analog of `Agent`) or
`OpenAIGroundedVoiceAssistant` (Brain → Presenter, can't drift from data — analog of `GroundedAgent`).
Every component is a `Sendable` protocol with one built-in implementation — swap in your own anywhere.
## Modules (import only what you use)
| Import | Pulls in | Contents |
|---|---|---|
| `AgentSquad` | nothing external | protocols, `Agent`, `GroundedAgent`, `Orchestrator`, `LLMClassifier`, `ChatCompletionsClient`, `DakeraRetriever`, `FileChatStorage`, `DeviceChatStorage`, `InMemoryChatStorage`, `TransformingChatStorage`, `SummarizingChatStorage`, `OSLogTracer`, OTLP export |
| `AgentSquadMCP` | MCP Swift SDK | `MCPServer` (= `MCPToolProvider`), `SDKMCPClient` |
| `AgentSquadAudio` | AVFoundation | `VoiceProcessedAudioIO` (capture+playback, one engine, AEC — the recommended wiring), `MicCapture` (voice-processed/AEC by default), `AudioPlayback`, `VoiceProcessing`, `AudioSessionPolicy` (needs `NSMicrophoneUsageDescription`) |
SwiftPM: `.package(url: "https://github.com/2FastLabs/agent-squad", branch: "main")`.
## How a turn works
Two peer runtimes share contracts but not a control loop: a turn-based **`Orchestrator`**
(`classify? → run agent → stream → persist`) and a long-lived **`VoiceAssistant`** for voice. Either
way you consume an `AsyncThrowingStream<AgentEvent, any Error>` — the one idiom worth memorizing:
```swift
for try await event in orchestrator.route(.text("hello"), userId: "u1", sessionId: "s1") {
switch event {
case .textDelta(let token): /* stream tokens */
case .final(let message): /* the message that was persisted */
case .toolCall, .widget, .thinking, .error: break // .error is a user-facing string
}
}
```
`.error` carries a *user-facing* message; real programmer/transport failures **throw** through the
stream. `.final` is what the orchestrator persists. Inputs/messages are value types
(`AgentInput.text`, `ConversationMessage`, `ContentPart`, `JSONValue`) in `Sources/AgentSquad/Core/`.
## The pieces
- **`Orchestrator`** drives a turn. The **classifier is optional** — omit it for a single agent.
- **`Agent`** is one LLM with an internal tool loop. **`GroundedAgent`** is two LLMs (Brain + isolated
Presenter) for answers that must stay grounded in tool results. The Presenter never sees chat
history or the Brain's transcript; `presenterInput` picks `.questionAndData` (default) or `.dataOnly`.
- **`ChatCompletionsClient`** speaks the OpenAI wire — point its `baseURL` at OpenAI, Azure,
OpenRouter, Groq, or a local Ollama/llama.cpp. Implement `LLMClient` for anything else.
- **Tools** come from a `ToolProvider`. Built-ins: **`ToolKit`** holds native tools — `Tool.local`
(Swift closure) and `Tool.http`/`Tool.get`/`.post` (declarative HTTP, with a `ToolParameter` DSL so
you don't hand-write JSON Schema); **`HTTPToolGroup(baseURL:…)`** declares one API's shared
config once, then one line per endpoint; **`MCPServer(url:)`** connects an MCP server; and
**`AggregateToolProvider`** composes any mix behind one seam; **`DakeraRetriever(namespace:…)`** is a
`ToolProvider` backed by a self-hosted [Dakera](https://dakera.ai) memory server — it exposes a
`search_memory` tool for grounding (and a direct `retrieve(_:)` API), talking to Dakera's REST
endpoint over `URLSession` with no extra dependency. A `ToolResult` is three-part: text →
the model's context, `structuredContent` → curator/UI data, `ui` → an optional widget.
- **`FileChatStorage`** (JSON files, iOS 16+) and **`DeviceChatStorage`** (SwiftData, iOS 17+) persist history on-device; **`InMemoryChatStorage`** is a non-persistent, seedable single-conversation store. **`TransformingChatStorage`** wraps any store and runs a `MessageTransform` before each save (PII scrub / redact / drop) — reads pass through, `message.mappingText { … }` covers the text-only case. **`SummarizingChatStorage`** wraps any store and keeps agent context small: on the first fetch that exceeds `triggerAt` message pairs the user-supplied `ChatSummarizer` is called and the compressed result is held in an in-memory buffer; subsequent saves append to the buffer and recompress eagerly if needed; `fetchAllChats` is never intercepted so raw history stays available for analytics — the inner store is never written by the summarizer. **`OSLogTracer`** is the default
tracer; wire `ProcessingTracer` + `OTLPExporter` to ship traces to Langfuse/LangSmith/Datadog/…
- **Voice**: two `VoiceAssistant`s over a WebSocket — `OpenAIVoiceAssistant` (single LLM, speaks
directly) and `OpenAIGroundedVoiceAssistant` (grounded Brain → Presenter). Both are self-sufficient
(own `tracer`/`store`/`userId`/`sessionId`; with a `store`, completed turns persist and prior
history seeds on `start()`), wired to the mic/speaker by `RealtimeRuntime`. Preferred audio
wiring: ONE `VoiceProcessedAudioIO` instance passed as both `input:` and `output:` — capture and
playback share one voice-processed `AVAudioEngine`, so the assistant's audio is guaranteed to be
in the echo canceller's reference path. The split `MicCapture`/`AudioPlayback` pair also works
(capture is voice-processed by default; the AEC reference is then device-level/route-dependent;
`MicCapture(voiceProcessing: nil)` = raw capture). All three audio classes take an
`AudioSessionPolicy` (`.managed` / `.custom` / `.external` for apps that own the
`AVAudioSession`) and a `configureEngine` hook exposing the raw `AVAudioEngine`.
Session tuning on both: `transcriptionModel` (the user's STT only), `turnDetection`
(`.semanticVAD(eagerness:)` / `.serverVAD(threshold:…)` / `.disabled`), and `sessionOverrides`
(deep-merged into the generated `session.update` last — the escape hatch for unmodeled keys like
`audio.input.noise_reduction`). On `OpenAIVoiceAssistant` additionally
`reasoning: RealtimeReasoningEffort` (`.minimal`…`.xhigh`, session-wide, reasoning models like
gpt-realtime-2 only) and `toolReasoningEffort: [String: RealtimeReasoningEffort]` — turn-sticky
escalation: once a turn calls a listed tool, that turn's subsequent responses are created with
the mapped effort (highest wins), so payload synthesis thinks harder while lookups stay fast.
## Custom implementations
Conform to the protocol and pass your type where the built-in goes. Each seam has a worked example on
its doc page (paths below are under `docs/src/content/docs/swift/`, published at `/agent-squad/swift/…`);
signatures live in `Sources/AgentSquad/`.
| Seam | Protocol | Source · doc |
|---|---|---|
| Agent | `AgentProtocol` | `Core/AgentProtocol.swift` · `agents/custom` |
| Classifier | `Classifier` (return an agent from the passed list, or `nil`) | `Core/Classifier/` · `classifiers/custom` |
| LLM client | `LLMClient` | `Core/LLMClient.swift` · `llm/custom` |
| Tools | `ToolProvider` | `Core/Tooling/` · `tools/custom` |
| Tool-output curator | `ToolOutputCurator` (where you trim oversized output) | `Core/Presenter/` · `ui/built-in/curators` |
| Presenter prompt | `PresenterPrompt` | `Core/Presenter/` · `agents/built-in/grounded-agent` |
| Storage | `ChatStorage` | `Core/Storage/` · `storage/custom` |
| Tracing | `TraceExporter` (easiest) / `SpanProcessor` / `Tracer` / `Redactor` | `Core/Tracing/` · `tracing/custom` |
| Realtime transport | `RealtimeTransport` | `Runtimes/Realtime/` · `voice/custom` |
| Audio I/O | `AudioInput` / `AudioOutput` | `Runtimes/Realtime/AudioIO.swift` · `audio/custom` |
## Gotchas
- **`maxToolRounds`**: `Agent`/`GroundedAgent` default to `20`; the `AgentProtocol` default is `1`. A
custom agent that injects tools but leaves `1` silently disables its tool loop.
- **Classifier is optional**: no classifier ⇒ no routing hop / no extra model call. A `nil` selection
falls back to the default agent (no confidence threshold).
- **Persistence**: only turns ending in `.final` are saved.
- **`ChatCompletionsClient`**: retries only *before the first event*; some local runtimes reject
`stream_options`/unknown body keys — override via `extraBody`.
- **`JSONValue`**: whole-number doubles decode to `.int`; carry large IDs as `.string`.
- **Storage**: `FileChatStorage` (JSON, iOS 16+, scopes per-call by `userId`/`sessionId`/`agentId` — e.g. `sessionId` to isolate per match) or `DeviceChatStorage` (SwiftData, iOS 17+, bound to one `userId`). Both default to Library/Caches (disposable). `InMemoryChatStorage` (iOS 16+) is non-persistent and holds one conversation — construct it empty or seeded with a prior conversation to load one into a session. Wrap any store in `TransformingChatStorage` to scrub/redact before persistence; prefer redacting over returning `nil` (dropping one side of an exchange can make the store skip its counterpart via the consecutive-same-role guard). Wrap any store in `SummarizingChatStorage(wrapping:summarizer:triggerAt:keepLast:)` to keep agent context small: the buffer activates lazily on the first qualifying fetch; once active, saves append to it and compress eagerly; `fetchAllChats` bypasses the buffer entirely.
- **Tracing lifecycle**: nothing drains the tracer for you — flush on background, shut down on
termination. `OSLogTracer` logs no payloads. `Redaction` hashes ids + clips strings but does **not**
pattern-scrub PII — supply a custom `Redactor` for that. A realtime answer generation
(`response`/`presenter`) is backdated to its `response.created` receive-time via
`SpanHandle.generation(…, startedAt:)`, so the exported span carries the real call latency instead
of a ~0 duration (the Realtime API sends no server-side timing). The overload defaults to stamping
now, so custom `SpanHandle`s need not implement it.
- **Realtime** is a peer runtime, not an agent; its `events` stream is non-throwing; needs
`NSMicrophoneUsageDescription`; always `stop()`. In-band failures arrive as
`.error(code:message:)` — `code` is the API's machine code (e.g. `rate_limit_exceeded`),
`response_failed` when the turn's live response ends with `status: "failed"` (a late failed
`done` for a response already cancelled by barge-in is consumed silently), or `transport_closed` when the
socket dies (then `events` finishes — the end-of-session signal); `message` is the
human-readable detail for logs, not for verbatim display. Failures are recorded on the trace
spans they end (turn/session/tool), so they export with `status: error` instead of vanishing.
- **Barge-in truncation**: on interrupt the session sends `conversation.item.truncate` so the
server drops the unheard audio + transcript from context (OpenAI docs' WebSocket procedure).
Automatic when wired by `RealtimeRuntime` with the built-in outputs; a custom `AudioOutput`
gets it by implementing `playedMilliseconds()` (protocol default returns `nil` → truncation
is skipped, everything else works). Applies to in-band spoken replies only — the grounded
presenter is out-of-band (`conversation: "none"`), its items never enter the conversation,
so interrupting it deliberately sends no truncate.
- **Voice processing (AEC)**: on by default; if it can't be enabled `start()` throws
`.voiceProcessingUnavailable` (degrade deliberately with `MicCapture(voiceProcessing: nil)`).
For guaranteed echo cancellation use `VoiceProcessedAudioIO` and pass the **same instance** as
input and output (its `start()`/`stop()` are idempotent — the runtime calls each twice). The
simulator does **no** AEC — validate on a device. VP quiets the speaker (counter with
`duckingLevel: .min`); never enable VP on a playback-only engine. With
`sessionPolicy: .external` the app must configure **and activate** its `AVAudioSession` before
`start()`, and should use the same policy everywhere.
- **`ContentPart` Codable** keys off case + label names — renaming breaks stored history.
## Go deeper
- **Prose & recipes** — the Starlight docs under `docs/src/content/docs/swift/` (run the site from
`docs/` with `npm run dev`): `quick-start`, `orchestrator/overview`,
`agents/built-in/grounded-agent`, `mcp/overview`, `ui/overview`, `storage/built-in/device`,
`tracing/built-in/otlp-exporter`, `voice/built-in/openai-voice`,
`voice/built-in/openai-grounded-voice`, `guides/*`.
- **Exact signatures** — `swift/Sources/AgentSquad/` (`Core/`, `Agents/`, `Core/LLM/`,
`Core/Tooling/`, `Core/Tracing/`, `Runtimes/Realtime/`).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!