Composable on-device + remote agent runtime for Apple platforms. Native Swift agents, workflows, skills, JS plugins, MCP — FoundationModels / MLX / OpenAI / Anthropic / Gemini.
SaferSkills independently audited aria (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
<p align="center"> <picture> <source media="(prefers-color-scheme: dark)" srcset="Assets/aria-lockup-dark.svg"> <img src="Assets/aria-lockup-light.svg" alt="Aria" height="64"> </picture> </p>
Agents that run on the user's device, and survive contact with a 4,096-token window.
Aria is a Swift library for building agent-driven apps on Apple platforms. The default path runs on-device via FoundationModels or MLX; the same Agent surface drives OpenAI / Anthropic / Gemini when you need to reach further.
let agent = Agent(config: AgentConfig(
provider: FoundationModelsProvider(typedTools: [weatherTool]),
tools: [weatherTool.anyTool],
systemPrompt: "You are a concise assistant."
))
for try await event in agent.stream(.message(.user("What's the weather in Berlin?"))) {
if case let .textDelta(text) = event { print(text, terminator: "") }
}That much you could write against Apple's API directly. What Aria adds is everything that goes wrong afterwards.
Same six tasks, same on-device model, same prompts. One arm is a bare FoundationModelsProvider; the other adds a context assembler and a per-turn tool budget. Three trials of the corpus each:
| tool surface | bare provider | with Aria's context layer |
|---|---|---|
| 12 tools — fits the window | 83% | 78% |
| 50 tools — does not fit | 0% (every turn refused) | 83% |
Read the first row honestly: under about twenty tools, use Apple's API directly — the assembler has nothing to relieve and can only cost you.
That row survived a deliberate attempt to break it. The obvious objection is that Apple's API cannot rank, so selection should pay off before the window fills — and the first corpus padded with calculator and base64_codec was too easy to test that. Re-run at twenty tools with confusable near-neighbours instead (start_fast, end_fast, get_fasting_stats beside get_fasting_status), both arms fit the window:
| 20 tools | bare provider | with the context layer |
|---|---|---|
| easy distractors | 83% | 83% |
| confusable distractors | 83% | 83% |
No separation. The on-device model picks correctly among near-neighbours on its own, and ranking does not improve on it. Caveat worth stating: tool accuracy sat at 83% in every arm, so this corpus is saturated and could not have detected a small gain. The claim is "no effect large enough for six cases to see", not "no effect".
The second row is the reason this package exists. Connect one MCP server and fifty tools arrive at once; the request crosses 4,096 tokens and FoundationModels throws exceededContextWindowSize rather than truncating. Not a worse answer — no answer, on every single turn. That is the wall Aria is built for, and the crossover is the whole story.
Run it yourself: ARIA_RUN_EVALS=1 swift test --filter TaskEvalTests.
On-device models are small. The interesting problems are not "how do I call a tool" — they are what happens on turn nine, with thirty tools registered, a memory store, and a 4,096-token ceiling that refuses rather than truncates. Every item below is a failure observed in a shipping app, and the fix is in the box:
| What goes wrong | What Aria does |
|---|---|
| Request refused at 4,096 tokens, whole turn lost | ContextAssembler budgets prompt + tools + history together, and reports where every token went |
| 30 tools in the prompt; the model calls the wrong one | ToolSelector ranks per turn — lexical, embedding, or fused |
| A model invents a "fact" about the user; it is stored forever | MemoryGate provenance: a fact must trace back to what the user actually said |
| A tool fails and the model fabricates the answer instead | Failure guidance travels with the result |
| Swapping your embedding model silently empties memory | Vectors are keyed by embedder identity, with a re-embed migration |
A thinking model's <think> blocks eat the history budget | Past-turn reasoning is not budgeted |
None of these are theoretical. Release notes carry the traces.
Tool selection is the part most likely to be wrong for your tool surface, so AriaTesting ships the harness rather than just the numbers:
let report = await ToolSelectionEval(corpus: myTools, cases: myQueries)
.run(mySelector, label: "fused")
// fused: hit 100% · top-1 67% · MRR 0.79 · misled 0% · avg sent 7.5Measured on one 12-query corpus, each fused with lexical matching:
| encoder | hit | MRR | avg tools sent |
|---|---|---|---|
| lexical only | 67% | 0.61 | — |
+ NLEmbedding | 58% | 0.39 | — |
| + Apple contextual | 92% | 0.72 | 10.0 |
+ MLX bge-small | 100% | 0.79 | 7.5 |
Skills flood the same window, and get the same treatment. A catalogue is pasted whole into every turn unless it is ranked — and each irrelevant skill is not just its line in the prompt but a chance the model loads it, costing a round-trip and a body-sized result. Ten skills, five queries:
| catalogue | recall | irrelevant skills offered / turn | block size |
|---|---|---|---|
| unranked | 100% | 9.4 | 350 tok |
| ranked, lexical | 100% | 0.2 | 60 tok |
Recall is the constraint, not the headline: ranking that hides the skill a turn needs is worse than no ranking at all.
Apple's NLEmbedding measures worse than no encoder at all — averaged static word vectors put everything user-shaped near everything else. That is the kind of thing you only learn by measuring, which is why the harness ships.
summarization, RAG, fact extraction), provider-agnostic structured output.
ContextAssembler, ContextBudget, TokenCounter, ToolSelector.SKILL.md bundles with on-demand loading.regression tests.
Core is platform-agnostic and builds on Linux. Apple-specific implementations live in AriaApple.
Status: 0.x and moving. Used in production by two apps. Breaking changes land on minor versions and are listed in the release notes.
[String: Any].AsyncThrowingStream is the universal output shape; SwiftUI integration is trivial. ┌────────────────────────────────────────────────────────┐
│ HOST APP (avyra, niora, your app) │
│ Wires tool sources + LLM routing into the runtimes, │
│ ships UI, owns content (workflows, agents, skills) │
└──┬───────────────────────────────┬─────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ WorkflowKit │ │ AgentKit │
│ ── recipes you write │ │ ── goals you delegate │
│ • Codable Workflow │ │ • Codable AgentDefinition│
│ • CapabilityBroker │ │ • AgentRuntime + compiler│
│ • Step compiler → │ │ • ProposeTool + approval │
│ StateGraph │ │ • Checkpoint middleware │
│ • GRDB store │ │ • File-JSON stores │
└──────────┬───────────┘ └────────────┬──────────────┘
│ │
└─────────────┬────────────────────┘
▼
┌────────────────────────────────────────────────────────┐
│ Aria (core) │
│ Layer 6: StateGraph (optional graphs) │
│ Layer 5: Agent (tool-calling loop) │
│ Layer 4: Memory (history, vectors) │
│ Layer 3: Providers (LLM, Tool, Embedder) │
│ Layer 2: Runnable (composability) │
│ Layer 1: Foundation (data model) │
└────────────────────────────────────────────────────────┘
▲
│ binds to platform-specific impls
┌────────────────────────┴───────────────────────────────┐
│ AriaApple AriaMLX* AriaVoice AriaVoiceKokoro* │
│ ────────── ──────── ───────── ───────────────── │
│ FoundationModels Speech + AVSpeech Kokoro 82M │
│ GRDB memory STT/TTS TTS │
│ (* = SPM-trait-gated) │
└────────────────────────────────────────────────────────┘Mental model: the bottom (Aria core) is platform-agnostic and Linux-buildable; the platform tier binds it to Apple APIs; WorkflowKit + AgentKit are two peer runtimes sitting above that — WorkflowKit runs recipes you wrote, AgentKit runs goals you delegated. Both compose Aria's Agent loop + middleware; both call into CapabilityBroker for native side effects. Host apps pick either or both.
Within Aria, a layer depends only on layers below. See docs/architecture.md.
Sources/
├── Aria/ Layers 1–6, platform-agnostic (Linux-buildable)
├── AriaTesting/ Mocks, fixtures, SessionReplayer
├── AriaApple/ FoundationModels + GRDB-backed memory (Apple-only)
├── AriaTools/ Cross-platform tool implementations (HTTP, JSON, Regex, …)
├── AriaToolsJS/ JavaScriptCore-sandboxed user plugin runtime
├── AriaVoice/ Speech.framework STT + AVSpeechSynthesizer TTS
├── AriaMLX/ MLX-backed LLMProvider (opt-in via `MLX` trait)
├── AriaVoiceKokoro/ On-device Kokoro 82M TTS (opt-in via `VoiceKokoro` trait)
├── WorkflowKit/ Workflow runtime + CapabilityBroker + skills + plugin steps
├── AgentKit/ Agent runtime + AgentDefinition + ProposeTool + checkpoints
└── AriaCLI/ Demo CLI — records a run, prints the bundle, replays itWorkflowKit and AgentKit are independent of each other; either can be used standalone. The avyra app ships both — workflows for deterministic tile-tap tasks, agents for delegated goals.
Aria exposes two SPM traits (SE-0480, Swift 6.1+) for code paths with large transitive dependency graphs:
| Trait | Pulls in | Enables |
|---|---|---|
MLX | mlx-swift-lm, swift-huggingface-mlx, swift-transformers-mlx | AriaMLX — on-device LLM via MLX |
VoiceKokoro | kokoro-ios, mlx-swift, MLXUtilsLibrary | AriaVoiceKokoro — Kokoro 82M TTS |
Neither trait is on by default. SwiftPM still resolves the dependency packages (they appear in Package.resolved), but the target sources compile to empty unless the matching trait is enabled — so the heavy build / link cost only kicks in when a consumer actually uses the feature.
Enable from a consumer's Package.swift:
.package(
url: "https://github.com/prasadpamidi/aria.git",
from: "0.1.0",
traits: ["MLX", "VoiceKokoro"]
)Or in an Xcode project's "Add Package Dependency" dialog — tick the trait checkboxes when adding Aria.
The Fastfile wraps every trait combination:
bundle exec fastlane package_build # default traits
bundle exec fastlane package_build_mlx # --traits MLX
bundle exec fastlane package_build_voice_kokoro # --traits VoiceKokoro
bundle exec fastlane package_build_all # --traits MLX,VoiceKokoro
bundle exec fastlane package_tests # swift test
bundle exec fastlane quality # swiftformat + swiftlintimport Aria
import AriaApple
let storage = try GRDBStorage()
let timeKit = registerFoundationModelsTool(CurrentTimeTool())
let agent = Agent(config: AgentConfig(
provider: FoundationModelsProvider(typedTools: [timeKit.factory]),
tools: [timeKit.anyTool],
systemPrompt: "You are a helpful assistant.",
threadId: "main",
middleware: [HistoryMiddleware(history: storage.chatHistory)]
))
for try await event in agent.stream(.message(.user("What's the time in Tokyo?"))) {
switch event {
case let .textDelta(chunk): print(chunk, terminator: "")
case let .toolCallRequested(call): print("[calling \(call.name)]")
case .finish: print("\n[done]")
default: break
}
}AriaApple accepts any Foundation Models LanguageModel, so an app can run a Core AI model through the same LLMProvider, agent, tool, memory, and middleware surfaces as Apple's system model:
import Aria
import AriaApple
import CoreAILanguageModels
import FoundationModels
let model = try await CoreAILanguageModel(resourcesAt: modelResourcesURL)
let provider = FoundationModelsProvider(
model: model,
capabilities: ProviderCapabilities(
modelIdentifier: "coreai.qwen3-0.6b",
supportsToolUse: model.capabilities.contains(.toolCalling),
supportsStructuredOutput: model.capabilities.contains(.guidedGeneration)
)
)The app owns the Core AI package dependency, model resources, device-eligibility checks, and fallback policy. Aria depends only on Foundation Models protocols and does not silently replace an injected model when it cannot load or execute.
Long-running threads need bounded context, a summary of older turns, and a durable memory layer. Compose the built-in middlewares — order matters (load → summarize → window → recall → extract):
import Aria
import AriaApple
let storage = try GRDBStorage()
let embedder = NLEmbeddingEmbedder()!
let memory = DefaultMemoryStore(
embedder: embedder,
store: storage.vectorStore(dimensions: embedder.dimensions)
)
let agent = Agent(config: AgentConfig(
provider: FoundationModelsProvider(),
tools: [],
systemPrompt: "You are a helpful assistant.",
threadId: "main",
middleware: [
// 1. Persistent transcript loaded from GRDB on beforeRun
HistoryMiddleware(history: storage.chatHistory),
// 2. Compress the older portion into a summary system message
// once the thread grows past 24 non-system turns
HistorySummarizationMiddleware(
triggerAfterTurns: 24,
keepRecentTurns: 6,
summarizer: { messages in
// Typically a cheap LLM call (gpt-4o-mini, on-device 3B)
try await mySummarizer.summarize(messages)
}
),
// 3. Hard window so the provider sees a bounded transcript
HistoryWindowMiddleware(maxTurns: 16, maxTokens: 4000),
// 4. Recall top-K user memories for the latest message
RAGMiddleware(memoryStore: memory, namespace: ["user", userId], topK: 5),
// 5. Auto-mine durable facts from each user turn (background)
FactExtractionMiddleware(
memory: memory,
namespace: ["user", userId],
extractor: { message in
try await myExtractor.facts(from: message)
}
),
]
))
// Bound disk growth across all threads — run on launch / nightly.
try await HistoryRetentionPolicy(maxThreadAgeDays: 90, maxThreadCount: 20)
.enforce(on: storage.chatHistory)Agent.respond(_:as:) works against FoundationModels and any cloud LLMProvider — the agent layer derives the schema from the Generable type and injects it as ResponseFormat.schema(...) (or .rawSchema(...) for opaque JSON Schemas that don't round-trip through Aria's typed JSONSchema):
@Generable
struct ActivitySuggestion {
var title: String
var summary: String
var steps: [String]
}
for try await event in agent.respond(.message(.user("Suggest something fun")),
as: ActivitySuggestion.self) {
switch event {
case .partial(let snapshot): render(snapshot) // optionals fill in over time
case .finish(let suggestion): commit(suggestion)
case .toolCallExecuted: break
}
}A complete record + replay loop in 20 lines (cross-platform, no Apple deps):
import Aria
import AriaTesting
// 1. Record an agent run
let recorder = SessionRecorder()
let recording = RecordingMiddleware(recorder: recorder)
let provider = MockLLMProvider(scenes: [.text("hello")])
let agent = Agent(config: AgentConfig(
provider: provider, tools: [], threadId: "demo",
middleware: [recording]
))
for try await _ in agent.stream(.message(.user("hi"))) {}
// 2. Bundle it (Codable, ship anywhere)
let bundle = await recorder.bundle()
let json = try JSONEncoder().encode(bundle)
// 3. Replay against a fresh agent
let replay = SessionReplayer.mockProvider(from: bundle.agent!)
let replayedAgent = Agent(config: AgentConfig(provider: replay, tools: [], threadId: "replay"))Run the same flow as a CLI demo:
swift run AriaCLI # records, prints the JSON bundle, replays itAria's Agent and WorkflowKit runtime accept any LLMProvider conformer — the on-device FoundationModels / MLX providers are the default path, but cloud OpenAI / Anthropic / Gemini / OpenAI-compatible endpoints work the same way. A workflow step declares serverProviderID: "openai-prod" (or leaves it nil for the default provider) and the runtime resolves the right transport at compile time.
What works today:
respond(_:as:) against anyLLMProvider — FoundationModelsProvider, MLXProvider, and the server-LLM resolver path in WorkflowKit cover the same agent + middleware stack.
ServerLLMProviderResolver so aworkflow can mix on-device steps with cloud steps in one graph.
CredentialStore so each serverprovider's API key + auth headers come from Keychain, not config files.
Known limitations / pending work:
OpenAI-compatible providers but the typed-tool surface still routes most cleanly through FoundationModels' @Generable path on Apple platforms. Cloud-provider tool calls go through the same Agent runtime but the JSON-schema → @Generable round-trip is opaquer; complex multi-tool runs may need a schema-level adapter per provider.
data: SSE vs Anthropic's event-typed SSE vs Gemini's protobuf responses). Aria normalizes to a single ProviderEvent stream; edge cases like mid-stream tool calls in newer Anthropic tool_use events are still being hardened.
limiting / retry behaviour across MCP transports (stdio, http) is a per-server contract that callers wire themselves.
details in some failure modes. Treat ProviderError as a hint, not a stable contract.
See docs/workflowkit.md for the routing / resolver shape and a worked OpenAI + on-device hybrid example.
swift build # build the package (default traits)
swift build --traits MLX,VoiceKokoro # build with the heavy traits
swift test # run all tests
swift test --filter AriaTests # core tests (Linux-safe)
swift run AriaCLI # run the CLI demobrew bundle # swiftformat, swiftlint
bundle install # fastlane
./scripts/install-hooks.sh # pre-commit hook (lint + format gate)Routine work goes through Fastlane lanes:
bundle exec fastlane package_build # swift build (default traits)
bundle exec fastlane package_build_all # all trait variants
bundle exec fastlane package_tests # swift test
bundle exec fastlane cli_demo # swift run AriaCLI
bundle exec fastlane lint # SwiftLint
bundle exec fastlane format # SwiftFormat (in place)
bundle exec fastlane quality # format --lint + lint
bundle exec fastlane quality fix:true # auto-fix bothSee AGENTS.md for the full lane reference.
docs/overview.md — what Aria is, who it's fordocs/principles.md — architectural principlesdocs/architecture.md — layered design and module layoutdocs/traits.md — SPM traits (MLX, VoiceKokoro)docs/workflowkit.md — workflow runtime, capabilities, server-LLM routingdocs/agentkit.md — agent runtime, definitions, propose-then-host-executesdocs/skills.md — Anthropic-style skill bundlesdocs/plugins.md — JS plugin tools (.aria-tool runtime)docs/platform-boundary.md — cross-platform disciplinedocs/observability.md — OTel tracing/metrics + session recording / replaydocs/layers/ — per-layer specsdocs/decisions/ — architecture decision recordsdocs/glossary.md — terminologySee CONTRIBUTING.md and AGENTS.md. Aria is a clean-room implementation; please read NOTICE.md before contributing.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.