Architecture
One conversational surface, real structured data underneath, all
intelligence on the device. This document describes the code as it is on
main; where something is planned rather than built, it says so.
System overview
flowchart TB
subgraph UI["Presentation (SwiftUI + Metal)"]
Thread["Thread<br/>tool, plan and citation cards, Undo"]
Spaces["Spaces: Today, Projects, Knowledge"]
Intents["App Intents / App Shortcuts<br/>Ask, Add task, What's blocking"]
Widget["Today widget (extension)"]
Settings["Settings, Integrations<br/>explicit opt-in"]
end
subgraph Agent["Agent layer"]
Orch["AgentOrchestrator<br/>turn state, journal group per turn, brain swap"]
Select{"AgentVoice.selectBackend"}
Planner["CompoundPlanner<br/>split, expand, thread context"]
Exec["PlanExecutor<br/>$N refs, postconditions, stop safely"]
Undo["UndoEngine<br/>validate all, then apply"]
end
subgraph Brains["ModelBackend implementations"]
FM["FoundationModelBackend<br/>native Tool + @Generable plans"]
MLX["MLXBackend (opt-in build flag)<br/>one or many tool_call envelopes"]
Fallback["IntentFallbackBackend<br/>IntentGrammar: CommandFrames + ParaphraseFrames + Lexicon (NLTagger)"]
Selector["ToolSelector (optional)<br/>about 8 of 24 tools per request"]
end
subgraph Tools["Tools"]
Registry["AgentToolRegistry<br/>24 backend-neutral specs"]
ToolBox["ToolBox<br/>canonical implementation, journals inverses"]
Dates["NaturalDateParser<br/>dates + exact phrase"]
end
subgraph Data["Data layer"]
Services["DataServices<br/>single write path + JournalService"]
Store[("SwiftData store")]
RAG["NotesRAG<br/>NoteChunk, BM25 + NLEmbedding"]
Index["SemanticIndex<br/>note vectors"]
EK["Integrations<br/>EventKit behind protocols"]
Snap["WidgetPublisher<br/>App Group JSON snapshot"]
end
Thread --> Orch --> Select
Select --> FM & MLX & Fallback
Selector -.->|"Focused tool list"| FM
FM --> Planner
Fallback --> Planner
Planner --> Exec
MLX --> Exec
FM --> ToolBox
Fallback --> Registry
Exec --> Registry --> ToolBox
Thread -->|Undo| Undo
ToolBox --> Undo
ToolBox --> Dates
ToolBox --> Services
ToolBox --> RAG
ToolBox --> EK
Undo --> Services
Spaces --> Services
Intents --> ToolBox
Settings --> EK
Services --> Store
Services --> Snap --> Widget
RAG --> Store
RAG --> Index
Layers
| Layer | Path | Responsibility |
|---|---|---|
| App | Sources/App |
Entry point, AppModel (one zoom scalar drives the thread/spaces transition), RootShell, App Intents |
| Agent | Sources/Agent |
AgentOrchestrator, ToolBox, AgentToolRegistry, ToolCallParser, UndoEngine, NaturalDateParser, BriefEngine; Planner/ (CompoundPlanner, PlanExecutor, AgentPlan); Router/ (IntentGrammar, CommandFrames, ParaphraseFrames, Lexicon, ToolSelector) |
| Intelligence | Sources/Intelligence |
ModelBackend protocol, the three backends, SemanticIndex; Retrieval/ (NoteChunker, BM25Index, NotesRAG, CitedAnswerComposer) |
| Integrations | Sources/Integrations |
CalendarReading / RemindersWriting protocols, EventKitCalendar / EventKitReminders, AgendaComposer, ReminderMapper, WidgetPublisher |
| Data | Sources/Data |
SwiftData models (including JournalEntry, NoteChunk, RecurrenceRule, ReminderExport), DataServices, Store, SearchEngine, NotificationPlanner, JSON Exporter, SeedData |
| Shared and widget | Shared, Widget |
WidgetSnapshot (compiled into both targets) and the WidgetKit extension |
| Design system | Sources/DesignSystem, Sources/Shaders |
Tokens (theme, type, motion, layout), GlassSurface, components, haptics, LiquidGlass.metal |
| Features | Sources/Features |
Thread, Today, Projects, Knowledge, Spaces search overlay, Settings, Privacy story and onboarding |
The agent loop
ModelBackend is a small @MainActor protocol:
protocol ModelBackend {
var displayName: String { get }
func reply(to prompt: String) -> AsyncThrowingStream<AgentEvent, Error>
func resetConversation()
}
enum AgentEvent {
case toolStarted(name: String, summary: String)
case toolFinished(ToolEventRecord)
case text(String) // cumulative snapshot, not a delta
case done(finalText: String)
}
Every backend speaks this event language, so the thread UI does not know
which brain answered. A turn in AgentOrchestrator:
- Persist the user's
ChatMessage, set phase.thinking. - Iterate the backend's stream.
toolStarted/toolFinishedbecome inline tool cards (phase.acting);textsnapshots drive the condensation renderer (phase.streaming);donepersists the agent message with its tool records. - Brain swap on failure. If the stream throws before any text arrived
and the active brain is not the fallback, the orchestrator replaces it
with
IntentFallbackBackendand re-runs the same prompt once. This covers the case where a model reports itself available but cannot generate (for example, Apple Intelligence enabled with assets not yet downloaded). If that also fails, the turn ends with a retryable message. retryLast()re-runs the last prompt without re-posting the bubble.- Journal group. Before each turn the orchestrator sets
ToolBox.turnGroupIDto a fresh id, so every mutation in the turn (one tool call, a native multi-turn tool sequence, or a whole plan) is journaled under one group and "undo that" reverts all of it. Cards are matched by record id, so a plan card can update in place.
Backend selection
AgentVoice.selectBackend runs at launch and when the Settings preference
changes:
| Order | Backend | When | Tool calling |
|---|---|---|---|
| 1 | FoundationModelBackend |
SystemLanguageModel.default.availability == .available and preference is Automatic |
Native Tool conformances with @Generable argument structs; streamed snapshots; optionally only the tools ToolSelector picks for the request |
| 2 | MLXBackend |
Compiled only with -D POCKETBRAINS_MLX plus the mlx-swift-examples package |
System prompt lists the registry as JSON; the model replies with a <tool_call> envelope; ToolCallParser extracts it; up to 4 tool hops per turn |
| 3 | IntentFallbackBackend |
Always available; forced by Settings, Quick intents | routeTurn: compound requests become a plan (CompoundPlanner then PlanExecutor); single ones go through IntentGrammar to one registry call. Replies are templated and streamed word by word so the UI behaves identically |
Per-request tool trimming (Foundation Models)
The on-device model is offered every tool's schema on every request: 22
tools, or 24 with both integrations on. With Settings, Intelligence,
"Focused tool list" switched on (ToolTrimming, off by default),
FoundationModelBackend builds a one-request session that offers only
the tools ToolSelector picks:
- The deterministic grammar's own reading of the request scores
highest: the tool
IntentGrammar.parsereturns, each step of aCompoundPlannerplan, then the parse's fallbacks. - Lexicon verbs (by lemma) vote for their action's tools; for example a
reschedule verb votes for
rescheduleTaskandupdateTask. - Cue words vote for the tools they usually mean ("milestone", "overdue", "according to", "calendar", "reminders").
- A date phrase votes for the date-taking tools.
createTask, searchEverything, agenda and undo are always offered,
so a poor trim can still capture, search, answer with the agenda or undo.
At most 8 tools are offered (the default limit); only tools with a
positive score fill the room after the core, so simple requests often get
fewer. Ties break by registry order, so a request always gets the same
set, and tools the user has not opted into are never offered. Trade-off:
the trimmed session is fresh per request, so it does not carry
multi-turn context; compound requests still plan with the full catalog.
The eval measures recall@k of the trim (is the expected tool, or every
step of a compound request, inside the set); it does not measure what the
model then does with it.
Multi-step planning
A plan is an ordered list of PlanStep(tool, arguments). Argument values
may reference earlier steps: $2 is step 2's exact reference
(id:<uuid> of what it created), $2.title any named output, $last the
most recent step that produced a reference. Every service
find(matching:) resolves id: references before fuzzy title matching,
so a project created in step 1 is the one tasks are filed into, even when
a similarly named project exists.
Where plans come from:
- Deterministic:
CompoundPlanner.splitcuts on ", and then", ", then", " and ", commas and semicolons, but only where the next words open a command (a verb from a fixed list), so "call mum and dad" stays one task. Each clause is parsed byIntentGrammar; "add 3 tasks…" and "add tasks: a, b and c" expand to one step per task; tasks after acreateProjectare filed under it; pronouns resolve to the item the request is about. A clause nothing recognizes cancels planning (the request stays single). - Foundation Models: when a request splits into more than one clause,
a tool-less planning session generates a
@GenerableGeneratedPlan(steps of tool name plus name/value arguments,$Nreferences allowed). Unknown tool names reject the plan; the deterministic planner is the fallback. After execution a separate tool-less session narrates the outcome, so narration can never re-run a tool. Requests that do not split use native multi-turn tool calling; those calls are numbered as steps on their cards. - MLX: several
<tool_call>envelopes in one reply (ToolCallParser.parseAll) run as a plan.
PlanExecutor.execute resolves references, runs each step through the
registry, and checks a postcondition (the created task exists and is in
the requested project, the completed task is done, the link exists, and so
on). The first failed step stops the plan; if that step had written
something before failing its postcondition, its own journal entry is
reverted first. Remaining steps are marked skipped. The user sees a
message naming the step and the problem, and whether earlier steps are in
place (and undoable) or nothing changed. Events: a plan card with the
numbered outline, toolStarted/toolFinished per step with a "2/5"
label, and the final plan card (same id) with ✓ / ✗ / – marks and an
undoGroup.
Action journal and undo
Every mutating ToolBox method records a JournalEntry (tool, summary,
group, encoded [InverseStep]): delete what was created, reopen what was
completed, restore a task snapshot after an update, restore a note body
after an append, delete a link, move a repeat rule back, remove an
exported reminder. UndoEngine reverts one entry, a group, or "the last
turn" (undo tool, scope turn or step). It validates every inverse
before applying any, so a group either reverts completely or not at all
(for example, if a task was deleted behind the journal's back, its restore
cannot apply and nothing changes). Deleting something already gone counts
as done. Undo of a recurring task's completion moves the rule back to the
original and deletes the spawned occurrence.
Ask your notes (cited retrieval)
NoteChunkersplits a note into sentence-bounded passages of about 80 words (NLTokenizer), each repeating the previous passage's last sentence.NotesRAGstores them asNoteChunkrows with an NLEmbedding sentence vector when the asset exists.sync(notes:)re-cuts only notes whose content hash changed and drops chunks of deleted notes; it runs before every question, so edits from the note editor, undo or the agent are always reflected.upsertruns immediately after agent writes.- Ranking fuses BM25 over title plus passage tokens (stemmed, stopwords removed) with cosine similarity by reciprocal rank (k = 60); candidates need a positive keyword score or a cosine above 0.45.
CitedAnswerComposerpicks the passages' sentences that best overlap the question and numbers sources by first use, one number per note. The deterministic brain shows that extractive answer; the model brains get the same numbered passages with the instruction to answer only from them and cite[n]. Citations travel on the tool record and render as chips that open the note.
System integrations (opt-in)
EventKit is confined to EventKitBridge.swift behind CalendarReading
and RemindersWriting, created lazily on first opt-in use. Integrations
answers "ready" only when the Settings switch is on and iOS has granted
full access. calendarAgenda reads events for a DayWindow ("this
afternoon" starts now if the afternoon is under way) and adds free
stretches and tasks due; AgendaComposer and ReminderMapper are the
pure, tested mapping. exportToReminders records each export in
ReminderExport so a task is never exported twice and undo deletes
exactly those reminders. Foundation Models is offered the two tools only
after opt-in. The Morning Brief shows a calendar line when ready.
App Intents and widget
IntentRuntime holds one ToolBox on the shared container, so Siri
actions are journaled like thread actions. Shortcuts: Ask PocketBrains
(runs the selected brain, falls back to the deterministic one), Add Task,
What's Blocking <project> (a ProjectEntity with a string query), and
Capture Note. WidgetPublisher writes a JSON WidgetSnapshot (brief
headline, counts, next tasks) into the App Group container after every
task mutation and reloads the timeline; the widget extension only reads
that file. Without the App Group entitlement (unsigned builds) publishing
is skipped and the widget shows its placeholder.
All three share AgentVoice.instructions() so the voice is consistent.
Tools
ToolBox is the one implementation of every capability. The Foundation
Models bridge (FoundationModelBackend.swift), the registry used by MLX,
and the fallback router all call into it, and it calls into
DataServices. Every tool returns a ToolResult with a one-line summary
for the card, a detail the model reads, and succeeded.
| Tool | Arguments (required in bold) | Effect |
|---|---|---|
createTask |
title, due, priority, project, repeats | Creates a task; due parsed by NaturalDateParser; a repeat rule attaches a RecurrenceRule and sets the first due date |
completeTask |
query | Marks a task done; a recurring task spawns its next occurrence |
updateTask |
query, due, priority, project | Reschedules, reprioritises or moves a task; reports "no changes" honestly |
rescheduleTask |
query, to, by | New absolute date, or a shift from the current due date |
snoozeTask |
query, until | From today: tomorrow by default, "3 days", "until Monday" |
setPriority |
query, priority | Strict parse; unknown words fail instead of guessing |
setRecurrence |
query, rule | Daily, weekdays, every N weeks or days, or "none" |
queryTasks |
filter, project | today, overdue, blocked, upcoming (7 days) or all |
createProject |
name, summary | Creates a project with an identity hue |
projectStatus |
name | Progress, blockers and what they wait on, next milestones, recent activity |
addMilestone |
project, title, target | Adds a dated milestone |
listMilestones |
project | Milestones, soonest first, with reached marks |
createNote |
title, body, project | Captures and indexes a note, auto-weaves links |
appendNote |
query, text | Appends to a note and re-indexes it |
askNotes |
question | Retrieves passages and answers with [n] citations |
searchNotes |
query | Semantic plus keyword note search |
searchEverything |
query | Tasks, projects and notes in one query |
notesFrom |
day | Full text of notes created or edited on a day |
linkItems |
from, to, relation | Typed knowledge-graph edge |
agenda |
none | Overdue, due today, blocked, and this week |
calendarAgenda |
window | Opt-in: events, free stretches and tasks due in a day window |
exportToReminders |
scope | Opt-in: copies tasks to Apple Reminders, idempotent and undoable |
recall |
query | Searches past conversation and completed work |
undo |
scope | Reverts the last turn (whole plan) or only the last action |
Task and project lookup try a verbatim title match first, then fall back to stemmed content-word overlap (every query word, or all but one for queries of three or more words), preferring open tasks.
Deterministic fallback router
IntentGrammar.parse turns a clause into a ParsedIntent (tool,
arguments, optional fallbacks) without executing it, so the planner can
build and show a plan first. Order:
CommandFrames.beforeCapture: phrasings the capture rules would mis-read ("remind me about X later" is a snooze; "every Friday I need to…" is a recurring task).- The v0.1 capture rules (remind me, add a task, todo, I need to, note,
jot), now cutting a repeat phrase (
Recurrence.phrase) and the exact date phrase (NaturalDateParser.match) out of titles. - Undo.
CommandFrames.commands: a slot grammar over the lowercased clause for the v0.2 tools (reschedule, snooze, priority, recurrence, milestones, append, search everything) and common rewordings of v0.1 intents (done with X, how is X going, add X to my list). Slot values are cut from the original text by offset, so capitalization survives.- The v0.1 rules: ask your notes, calendar, Reminders export, agenda, blockers and status, completion, summaries, links, search, recall, new projects.
CommandFrames.paraphrase: tokens and lemmas from NLTagger (.lemma,.lexicalClass; a rule-based lemmatizer covers missing assets); the first verb whose lemma is in theLexiconsynonym sets decides the intent and the slots around it are filled the same way.CommandFrames.questionFallback(v0.3): a factual wh-question nothing else claimed goes toaskNotes; one scoped to today or this week goes to the agenda.- Otherwise the agenda, with an honest note about quick-intent mode.
v0.3 adds ParaphraseFrames (constructions rather than sentences),
tuned only against the v0.3 dev split. Leading interjections ("Wait,",
"Not now,") are dropped first. Early frames, before the capture rules:
"remind me what/who…" is a question, "remind me about X tomorrow" moves an
existing task (or captures it if there is none), "ping me tomorrow to X",
a reminder asked for as a noun ("can I get a reminder to…"), and labelled
captures ("Task: …", "Idea: …", "keep in mind…"). Command frames, before
the v0.2 ones: statements of state ("the gutters are clean", "that's the
passport sorted", "X is really important", "X isn't a priority", "X is now
due Monday", "X comes round every 4 weeks"), subject-first requests ("can
the invoice slip to Thursday"), a shift without a preposition ("kick the
report out a couple of days"), past-tense reports ("paid the water bill"
completes "pay the water bill"), quoted text placed into a named note,
verbless project status ("Q3 planning progress?"), list states ("still
open", "stuck"), and searches over everything or over notes. The
Reminders export check moved ahead of the command frames so "push today's
list to Apple Reminders" is not read as a reschedule, and "do" left the
completion lexicon because, as an auxiliary, it turned questions into
completions.
NaturalDateParser covers relative days, weekdays (full, abbreviated,
"next"/"this"/"every other"), "in N days/weeks/months", "in a fortnight",
period ends, weekends, explicit dates (NSDataDetector) and a time of day.
The router is measured by the eval in Tests/Eval (243 synthetic
utterances in eight splits, including two frozen held-out paraphrase
splits whose misses are never itemized; results in the README).
Canonical and compound phrasing is gated at 100%.
Data layer
SwiftData models: TaskItem (named to avoid Swift Concurrency's Task),
Project, Milestone, Note, KnowledgeLink (typed edge between any
two entities), ChatMessage (persistent transcript with tool records),
EmbeddingRecord (vector cache keyed by a stable content hash), and in
v0.2 JournalEntry (inverse operations per mutation), NoteChunk
(retrieval passages), RecurrenceRule and ReminderExport. The v0.2
models are new entities rather than new attributes on existing ones, so
existing stores migrate by adding tables, and they use only attribute
types proven on the iOS 26 runtime (String, Int, Date, Date?, Data,
unique UUID); AAStoreDiagnostics inserts a journal entry to keep that
checked.
Several shapes are deliberate workarounds for traps found on the iOS 26
runtime during CI: task dependencies are stored as packed UUID Data
rather than a self-referential relationship, note tags as a joined string
rather than [String], and explicit relationship inverses are omitted.
docs/VERIFICATION.md records the investigation.
DataServices groups small synchronous services (TaskService,
ProjectService, NoteService, GraphService, JournalService). Agent tools, views and
App Intents all write through them, so an agent action shows up in Today,
Projects and Knowledge immediately, and vice versa.
Store.makeContainer creates Application Support before opening the
store. If the persistent store cannot be opened (for example, a failed
migration), it backs up the store files with a timestamped suffix, records
Store.migrationError, and falls back to an in-memory store instead of
crashing.
SemanticIndex uses NLEmbedding.sentenceEmbedding(for: .english),
caches vectors in EmbeddingRecord, and ranks by cosine similarity in
memory. That is sized for a personal corpus of thousands of notes; there
is no approximate-nearest-neighbour index. The embedder is disabled under
the test host, so tests exercise keyword search only.
Presentation and design system
- RootShell: the thread is home. A vertical pull scrubs
AppModel.zoom; the thread recedes into a glass dock while Today, Projects and Knowledge fan in. One scalar drives every property, so the transition is interruptible and cannot desynchronise. - Thread: a custom
TextRenderer(CondensationRenderer) reveals each glyph from blurred to sharp as it arrives, timed byChunkClock; tool activity renders as inline glass cards; thinking is an aurora orb. - Knowledge:
OrbitalLayoutplaces the focused node at the centre and others on rings by graph distance, animated byGraphSimulation. - Deep Glass:
LiquidGlass.metalprovides three stitchable shaders applied with SwiftUIlayerEffect:liquidGlass(rounded-rect SDF refraction, motion-tracked specular, identity tint),aurora(domain-warped noise) andspecularSweep(the streaming highlight).GlassSurfacefalls back to a plain material if shaders are unavailable; Reduce Motion freezes the living surfaces. Token rules live indocs/DESIGN.md.
Testing and CI
- Swift Testing suites in
Tests/(129 tests in 18 suites in v0.3): date parsing (relative to now and to a fixed reference date),ToolBoxand the new tools, the tool registry and tool-call parser, planning, argument passing, failure handling and undo, retrieval (chunking, BM25 recall@k, citation mapping, incremental index), integrations behind mocks and the widget snapshot, the intent router, orbital layout, the streaming chunk clock, a SwiftData insert-walk regression suite, the router eval, the tool-trimming recall eval, the v0.3 router constructions, and the "ask your notes" eval (61 synthetic questions over 22 synthetic notes: recall@1 and @3, answer-span hit rate, citation faithfulness and span attribution). - Tests run serialized with a per-suite time limit. Each test keeps its
in-memory
ModelContaineralive explicitly, because aModelContextdoes not retain its container. - The test host is inert: no Metal, CoreMotion or persistent store.
.github/workflows/ci.ymlruns on every pull request and push tomain: a Linux hygiene scan, then XcodeGen andxcodebuild teston the newest Xcode on amacos-26runner against an iPhone simulator. The job summary shows the test count and the eval table. A second step re-runs only the RAG eval with the sentence-embedding asset allowed (PB_EMBEDDING_EVAL=1,continue-on-error), so semantic and hybrid retrieval are measured when the runner has the asset and reported as skipped when it does not.- Every eval prints
EVALJSONlines..github/workflows/pages.ymlruns after a successful CI run onmain, downloads that run's build logs, andscripts/build_site.pyrenders the docs site (overview, eval tables, architecture, product brief, verification) to thegh-pagesbranch. The site never contains a hand-typed number.
What CI cannot cover: the Foundation Models path (including guided-
generation planning and grounded answers) needs an Apple Intelligence
device; the MLX path is not compiled in CI; EventKit permission flows,
Siri phrases and the widget on a home screen need a manual run. CI does
build and embed the widget extension. Manual checks are listed in
docs/VERIFICATION.md.