Apple client¶
The native Apple client under clients/apple/ is a SwiftUI application for iOS
27+ and macOS 26+ (ADR-0211). It consumes only the versioned HTTP API. It does not import
the Python server, run an agent loop, or create a second source of truth.
Build and connect¶
Open clients/apple/Veetbot.xcodeproj with a full Xcode installation and build
the Veetbot target for an iOS or macOS destination. The target has no
third-party dependencies. The companion Swift package supports command-line
compilation and transport/reducer tests:
make test-apple requires full Xcode and guarantees that Swift Testing suites
execute; it fails instead of accepting the Command Line Tools behavior that can
compile the bundle without running it. make test-apple-ui also requires full
Xcode. It uses a debug-only hook to resize the real macOS SwiftUI window,
terminates the app, and verifies the relaunched window has the same size. It
also exercises the Mac Email mode and exact draft approval journey. It
then selects available iPhone and iPad simulators and launches a debug-only,
in-process fixture to verify that historical rows open and switch conversations,
new-conversation rows open the chat surface, and selected transcripts render.
Email journeys cover mode switching, feedback, editing, learning controls and
compact-trait navigation on both simulator families.
make test-apple-ui runs make test-apple-ui-macos, then
make test-apple-ui-ios. CircleCI runs make test-apple and the Mac cases in
the required apple job, and the simulator cases in the required apple-ios
job at the same time.
The connection screen accepts an HTTPS base URL and a static bearer token.
Plaintext HTTP, embedded URL credentials, queries, and fragments are rejected.
The base URL may be stored in preferences. The token is stored as a
device-local generic Keychain password and is never written to UserDefaults.
The app uses the local Data Protection Keychain under its team-signed
application identifier, with synchronization disabled. The tracked project does
not pin a development team; each developer must select their own team under the
target's Signing & Capabilities settings. A
macOS upgrade from the earlier file-based Keychain item is attempted without
displaying an authentication prompt; if its ad-hoc signature no longer has
access, the user must enter the token once in the signed build. The transport
keeps an already-unlocked token only in process memory for the current app
session, so ordinary API requests do not repeatedly reopen Keychain. It refuses
redirects, uses the operating system trust store, and maps 401 to
re-authentication while preserving 403 as an authorization failure.
Once a connection is configured, the application-delegate adaptor requests
notification authorization and registers with APNs. The client mints one
client_device_id, stores it in the nonsynchronizing Data Protection Keychain
beside the bearer credential, and posts the APNs token and build-derived sandbox
or production environment to POST /v1/devices. System registration runs again
on launch and Apple invokes the same upload path whenever it rotates the token.
Forgetting a connection attempts to revoke that server device first, but still
deletes the local bearer and clears the local connection if revocation fails.
A 404 from the feature-gated device surface marks notifications unavailable
without preventing the rest of the client from using an older server.
The tracked target selects platform-specific push entitlements: iOS declares
aps-environment, and macOS declares
com.apple.developer.aps-environment. Enabling the push capability for the
application identifier and regenerating provisioning profiles remain owner
actions in the Apple Developer portal. Debug and release simulator builds remain
unsigned-build compatible, and the debug UI-test fixture suppresses the
permission request.
Approval status uses the API's uppercase five-value wire vocabulary. A pending
approval remains actionable in its tool card with Approve once and Deny controls.
A browser.act card instead names the action, the element and the page, marks
website text as such, and offers Allow once, Deny, and, when the server offers
it, Allow all actions for this task after a confirmation. The card displays the
server's site scope, limits and exceptions beside that choice. An active task permission shows
above the composer with its remaining actions and time and a Stop control
(ADR-0129).
Settings are an A2 page: "Settings" in the serif face under a small brand
mark, then a scrolling body on paper. Connection, Models, Website
Access, Appearance, and Data & Privacy cards group controls by user intent, and
iOS adds SMS Integration; each card is a surface with hairline rows, fields in
the a2Field well, the one primary action as a filled capsule and the rest as
hairline or quiet capsules. First-run setup shows the same body under "Connect
this Mac (or iPhone, iPad) to your Veetbot." and a sentence on where the token
is kept. The Models card (ADR-0119) reads and saves
/v1/settings/models under settings.read and settings.write: a chat model
and reasoning effort, which apply to new chats and to the next message, and a
memory model and effort limited to the evaluated combinations the server
offers. A server without the resource hides the pickers. The Connect
or Update Connection action sits inside the Connection card, while the footer is
limited to closing the configured settings surface. Configured macOS clients
open settings in a separate window that resizes horizontally and vertically and
restores its last frame; first-run setup remains embedded in the resizable main
window. The main window has a separate persisted frame, so its last size and
position are restored without interfering with the settings window. Appearance
preferences include app-wide text sizing and system, rounded,
serif, or monospaced typography. They are stored in device preferences and apply
immediately. System sizing preserves the platform's accessibility setting, while
the three explicit sizes use deterministic scales on both platforms. The
body face still defaults to the rounded face, because the system face runs
about two points wider per toolbar label and folds the iPhone Subscriptions
toolbar into an overflow menu; the system, serif and monospaced choices apply
everywhere.
Design tokens¶
The interface draws from one set of semantic colours in
clients/apple/Veetbot/Views/AppTheme.swift, the "A2 Dock" palette the owner
chose on MagicPath in October 2026. Each token carries a light value and a
night-green dark value and resolves with the view's appearance, so no screen
chooses a colour by mode. The tokens are:
| Token | Role | Light | Dark |
|---|---|---|---|
paper |
the page: transcript and reading surfaces | #fcfaf6 |
#0c211d |
paperDeep |
the sidebar and the mode bar | #f3f0e8 |
#091a17 |
surface |
a card on the page | #ffffff |
#14302a |
field |
a sunken input or segment | ink at 5 % | ink at 8 % |
ink, inkSoft, inkFaint |
text in three weights | #102923, #2f4640, #566a64 |
#ece6d8, #bccbc5, #95a8a1 |
teal, tealFill, tealText, tealWash |
marks, filled buttons, links, the owner's capsule | #0e8b78, #0b7a69, #086657, teal at 10 % |
#36d5bb, #36d5bb, #5ee3c9, teal at 12 % |
amber, amberText, amberLine, amberWash |
what needs the owner: a dot, a line of text, a card border, a wash | #e0a23a, #7a4a0e, rgba(201,128,26,.55), rgba(242,169,59,.12) |
#f2a93b, #f2a93b, #f2a93b at 50 %, #f2a93b at 10 % |
red |
failure | #b4452e |
#e07a62 |
hairline, hairlineStrong, selected |
dividers, borders, the chosen row | ink at 14 %, 28 %, 9 % | ink at 10 %, 22 %, 12 % |
Titles use the serif display face through appDisplayFont; body text follows
the owner's font-style preference through appFont, and tool detail uses the
monospaced face through appCodeFont. Capsule buttons come in three weights
(filled teal, hairline, quiet) in A2Controls.swift.
Chat is the first surface drawn with these tokens: the sidebar on paperDeep
with a "Needs you" group ahead of Recent, folders and Scheduled; answers on bare
paper behind a small teal mark; the owner's messages in tealWash capsules; each
tool run one sentence that opens into a monospaced ledger; a pending approval or
clarifying question docked above a translucent composer while the transcript
shows one amber "Waiting for your approval" line. On a compact window the chat
list slides over the conversation from the leading edge. Email follows (see
"Email mode" below), then Memory, People and Schedules (see "Memory and
People" below), and Persona, Settings and Connect complete the set: the
persona's entries as a card of hairline rows of fields, a server conflict and
each nomination from memory in an amber-edged card with Affirm as the filled
capsule and Decline as a quiet one. HairlineDivider,
FilterPillButtonStyle, the a2Field, a2List and a2Card modifiers, and
the reading-page pieces A2SectionHeading, A2KindLabel, A2RecordGroup,
A2Quote, A2EmptyState and A2SheetHeader join the shared controls for the
surfaces that follow.
Website Access lists the authenticated principal's browser profiles and lets the
user choose one READY profile for new conversations. Adding access, or
signing in again to a profile that is not revoked, uses Sign in on this device
by default (ADR-0128). The app opens the website in a private sign-in window:
a WKWebView with a non-persistent data store, the platform's own user agent,
no injected script or message handler, and top-level navigation confined to
the profile's origins. The user types usernames, passwords, and one-time codes
into the website itself, and the app reads none of them. When the user taps
I'm signed in, the app creates the profile if it is new, begins a device
ceremony, and sends that website's cookies and localStorage once, directly
to the isolated browser service's handoff address, with the ceremony's
single-use capability and never with the Veetbot API credential. It then
clears the window's data. No cookie, storage value, capability, or confirmed
page address is persisted, logged, or kept in view state. The service filters
and verifies the session and decides the outcome. Passkeys and identity
providers on other origins do not work in that window. Every profile row that
is not revoked has Sign in again, which opens the same window for that
profile. When a new website's sign-in cannot start on this device, or its
session is too large to hand over, the window offers Use Veetbot's remote
browser: it clears the window's data and continues with the remote ceremony
below. While any task permission is active (ADR-0129), a compact Task
permissions row at the end of Website Access shows how many, and its menu
stops any one of them at once.
Use Veetbot's remote browser remains the alternative. It sends the exact
public-HTTPS primary origin, optional additionalOrigins included in
allowedOrigins, and the login-page URL to Veetbot. Each additional value
must also be an exact public HTTPS origin. The client then presents a
separate Continue in web browser action for the server's five-minute,
single-use browser ceremony. A rejected system-browser handoff cancels the
ceremony and removes its unused profile; a ceremony-creation failure also
rolls its partial profile back. In that mode the user enters usernames,
passwords, passkeys, and MFA directly in the isolated browser surface; the
app has no website-credential fields and receives no keystrokes, cookies,
storage state, or provider material. It reads only the secret-free ceremony
status, when the user asks and whenever the app becomes active or Website
Access appears.
The direct surface gives numbered focused-field instructions and identifies a
closed, reloaded, incomplete, or expired one-time link. The app exposes Start
over to remove that setup and obtain a fresh ceremony.
The selected opaque profile UUID is a device preference, is cleared when the
server connection changes or credentials are forgotten, and is included only
when the client creates a new session. The server revalidates ownership and
readiness before persisting that binding.
Data & Privacy displays the installed marketing version and build number. The first build with recoverable Website Access is version 0.1.1 (2), so an older installed binary can be identified without comparing source revisions.
TestFlight delivery¶
The apple-testflight CircleCI job archives the generic macOS destination from
each tested main revision after the production API deploy succeeds. It uses
CircleCI's project-scoped pipeline number as the build number without editing
the tracked Xcode project, verifies the archived build number, bundle identifier,
and code signature, and uploads directly to App Store Connect. Xcode's automatic
build-number management is disabled during export so Apple receives the value
the job inspected. The signed archive is not retained as a CircleCI artifact.
The job installs the CircleCI-managed veetbot-app-store application-signing
bundle, imports the installer identity from the restricted
veetbot-apple-signing context into a job-owned temporary keychain, and uses
the separate restricted veetbot-apple-testflight context for App Store
Connect authentication. The setup procedure and exact context variables are in the
production deployment guide.
The first pipeline number used this way must be greater than the latest macOS
build already accepted by App Store Connect.
App Store Connect must assign accepted builds to the intended TestFlight group, with automatic distribution enabled if every build should become available without operator action. Each Mac must install Veetbot through TestFlight and enable automatic updates there. Upload success means Xcode handed the build to Apple successfully; later processing, group assignment, and installation remain external states visible in App Store Connect and TestFlight.
Runtime behavior¶
One submitted message creates one run. A stable idempotency key is reused across
connection retries. If the server reports active_run_exists, the UI attaches
to the named run and does not queue a second message. A waiting user's answer is
routed to run input with the displayed question identifier. Stop requests use
the run-cancel route. The native composer uses padded multiline input; Return
sends its contents, while Command-Return inserts a newline.
Files can be attached to a message (ADR-0120): dropped on the conversation or
the message field on Mac and iPad, or chosen with the paperclip from Files on
every device and from Photos on iOS. Each file uploads to
POST /v1/sessions/{id}/artifacts as soon as it is added, with progress, retry
and removal, and a message may be attachments alone. Images other than small
PNG, GIF, and WebP files are re-encoded as JPEG with a 2000-pixel long edge,
which drops their location metadata. A server without the upload route marks
each file as not accepted, and an answer to a clarifying question stays text.
Submitting a top-level message inserts its user bubble before the first network
await. The persisted user.message.created event replaces that optimistic item
in place, preserving its position without rendering a duplicate. A submission
failure before server acceptance removes the optimistic item so the restored
composer remains the single retry surface.
The SSE reader parses the response incrementally. A bounded byte-to-line
decoder preserves the empty lines that delimit SSE frames because
URLSession.AsyncBytes.lines omits those separators on the supported Apple
platforms. Only persisted session sequences advance the replay cursor;
transient deltas are best effort. It does not infer a gap from non-contiguous
sequence values. Disconnects and overflow reconnect with Last-Event-ID,
suspension keeps the logical stream alive, and only completed, failed, or
cancelled run events close it. Raw reasoning text is discarded at the reducer
boundary and represented by a compact activity indicator. A reasoning summary
contributes only the heading of its latest part, which the indicator shows as
"Thinking:
The conversation's sidebar title is the window title on the Mac and the navigation title on iOS, with no repeated heading in the pane. The server writes a model-generated title a few seconds after a reply ends and changes it only when the subject moves (ADR-0155), so the client reads the session list once more ten seconds after a reply it watched ends, besides its 30-second poll. Beneath it, a status names only what the run needs from the owner: Needs your approval while an unanswered approval is pending, and Needs your answer for a question. A run still parked as waiting for approval with nothing left to decide, because an answered approval is resuming or a delegated child is working, shows Working… in the activity indicator instead. People and Stop sit in the window toolbar on the Mac and in the navigation bar on iOS.
The sidebar mirrors the server's authoritative, paginated session index.
SwiftData stores that cache behind the store protocol; if its container cannot
be opened, the client logs the failure and keeps the cache in memory for that
launch, refilled from the index on connect (ADR-0211). The cache contains only
session_id, title, agent identity, timestamps, the last known run ID, the
server-assigned folder identifier, and a scheduled session's schedule
identifier. The
client follows pagination until the server returns no next cursor, rejects a
repeated cursor as an invalid response, and reconciles that complete index after
connecting, whenever it returns to the foreground, and every 30 seconds while
it remains open. The first top-level user message provides an optimistic local
title from its first non-empty text block; if that message has no non-empty text
block, the title remains unset. The server stores the authoritative normalized
title and recovers titles for older sessions from their first user-message
event. Moving to a new machine therefore does not turn established
conversations into New conversation rows.
Server sessions are inserted or refreshed and local rows absent from the
authoritative index are verified with scoped point reads under a bounded
concurrency limit before they are pruned. Those point reads prevent
activity-driven movement between keyset pages from looking like a deletion
without serializing a large history into one request per round trip. Confirmed
pruning also clears the process-local artifact cache. People audit and import
sessions are not conversations: the sidebar skips them and prunes cached copies
without deleting them on the server, because they carry People evidence.
Pruning a cached operational row preserves any focused approval or question the
owner has opened, including when its selection races with the history refresh.
Conversation activity, not selection, updates the server
ordering. Each row's activity timer shows seconds only during its first minute,
then uses minute-or-larger relative units.
On macOS, iPhone, and iPad, a collapsible Recent chats section immediately below New conversation shows up to five conversations in descending activity order, including conversations inside closed folders. Scheduled sessions are excluded. These are shortcuts to the same conversations: their folder and History rows remain in place, and selecting a shortcut does not change its activity or folder. The section starts expanded, remembers the owner's toggle on the device independently of folder solo mode, and works with older servers that do not offer folders. An empty history shows no Recent chats section.
Conversation folders (Milestone 29, thread-folders.md) are server state the
sidebar mirrors, never a local organization. The index's folder_id is merged
into the cached row on every reconciliation and the server's value wins, nil
included, so a move made on another device lands on the next poll. A server
whose index carries the folder_id key is asked for its folders and open
proposals with the history; both are held in memory only. The sidebar then
renders suggested folders, each listing the conversations it would file one
per line, with accept, decline, and for a new folder a rename that accepts it
under the owner's name; then the folders in one section, each a row that
expands to its conversations and whose context menu (a right-click on Mac, a
long press on iPhone and iPad) renames or deletes it or switches solo mode;
then the unfiled history and a new-folder control; and every row gains a move
menu listing the folders. Which folders are expanded is remembered on the
device and changed only by the owner's own toggles, never by a proposal or a
refresh. In solo mode, the default, at most one folder is open and opening one
closes the other; without it every folder starts open. Create, rename, and
accepting under a new name use a sheet whose refused or duplicate name is
shown inline, never in the global error banner; deleting a folder uses the
same confirmation idiom as deleting a conversation and says that the
conversations return to history.
Unfiling sends an explicit null, so an omitted field can never unfile a
conversation. Every folder mutation — a move, a create, a rename, or a delete —
fences reconciliation when its answer arrives, and a move or a delete also when
it starts, so an index or folder page read while the mutation was in flight
cannot put the conversation back, revive the folder, restore the old name, or
drop the new one. Against a server whose index lacks the key, or that answers 404
or 405 on the folder list, the client makes no further folder request, keeps
every folder control hidden, and renders the history without folders; that unavailability
is contained in reconciliation and never surfaces as an error.
Scheduled sessions never enter a folder (ADR-0113, scheduling.md). The
index's schedule_id metadata is cached with each row, the server's value
winning, and a schedule with two or more cached sessions renders as one
collapsible row in a Scheduled section between the folders and the unfiled
history, labelled with its newest session's title and holding its sessions in
activity order. A lone scheduled session stays in the history. Groups start
collapsed; the owner's toggles are remembered on the device. Scheduled rows
carry no move menu, and the groups render whether or not the server offers
folders.
In compact iPhone and iPad layouts, sidebar rows push an activating chat destination before selecting a historical session or resetting to a new conversation. On regular-width iPad layouts and macOS, where the split-view detail is already visible, those rows activate the detail directly. This adaptive navigation prevents a compact-width tap from mutating an unbound sidebar value and prevents a regular-width selection from leaving the visible detail stale.
A notification tap accepts only the closed, content-free veetbot payload
dictionary. If it names a session and run, the client restores the complete
durable transcript before attaching to that exact run. Approval and question
notifications then focus their corresponding card; a cold-launch tap waits for
the saved connection to install before following the same path. The client keeps
only transient navigation focus and never persists notification state; offline
recovery remains the server's /v1/notifications authority.
Notification alerts explain the next action, include the tool name for approvals and device actions when available, and distinguish schedule outcomes and skip reasons. Schedule alerts also identify the title from the revision that ran and its scheduled occurrence time, even if the schedule was renamed afterward (ADR-0091). Conversation text, instructions, recipients, message bodies, run results, and failure details are fetched after opening the app. Existing clients can open the enriched alerts because their version-1 tap dictionary is unchanged.
Approvals, questions, and scheduled outcomes wait thirty seconds before push delivery, giving you time to handle them on another device. A loaded conversation visible in the active Chat window suppresses its foreground alerts. Active clients also clear delivered alerts once the server confirms they are obsolete, and viewing a terminal result acknowledges it across devices. Dismissing the compact conversation drawer also clears coverage from its removed sidebar sheets; notification navigation can then acknowledge the visible report. Covered or background conversations do not count as viewed. Sleeping devices clear obsolete alerts when next active; a banner already shown cannot be recalled (ADR-0143).
On macOS, System Settings → Notifications → Veetbot → Allow Notifications
controls permission. On iOS, use Settings → Notifications → Veetbot.
The client checks existing authorization before asking, respects a denial
without showing a repeated application error, and still reports other push
registration failures. After turning permission on, relaunch Veetbot to register
for push delivery. A denied permission is separate from signing: a distributed
macOS build must also contain the com.apple.developer.aps-environment
entitlement, while iOS uses aps-environment.
Deleting a row is an irreversible Delete Everywhere operation. The client
first asks the server to delete the session and its associated conversation
data, then removes the local history row and cached artifact bytes only after a
successful response. A session with an active run returns 409; the user must
stop that run before deleting. The same principal may safely repeat a completed
delete. Starting deletion fences in-flight reconciliation, and a successfully
deleted identifier remains excluded from later stale responses. Other open
clients remove the row on foreground reconciliation or their next active-phase
poll. A server release that predates the history routes produces an explicit
server-upgrade message during reconciliation or deletion rather than the generic
unsupported-request response. Initial connection setup propagates that
compatibility failure, or a reauthentication response, back to the settings
form so it remains open instead of reporting a successful save.
Opening a conversation writes notice-level lines to the unified log under
subsystem com.veetbot.apple, category conversation: the selection, the
restored message and row counts, each superseded or failed load, the end of the
run replay, and, three seconds after selection, the rows held against the
transcript's laid-out content and viewport heights. They carry identifiers and
counts only, never conversation content. When a conversation opens blank, read
them on that Mac with
/usr/bin/log show --last 1h --predicate 'subsystem == "com.veetbot.apple" AND category == "conversation"';
a settled line with rows but no content height means the transcript failed to
draw state it holds.
Agent activity and artifacts¶
The timeline includes generic, collapsible tool activity, approval checkpoints, clarifying questions, working state, and artifact links. Tool presentation is driven by effect and risk taxonomy rather than a per-tool icon table. Structured sandbox and workspace results receive terminal and file-preview treatments when those fields are present. Conversation text renders Markdown headings, emphasis, links, lists and task lists, block quotes, thematic rules, code blocks, and tables. Every fenced or indented code block, including shell and CLI blocks, has a labeled Copy action that writes the block's exact contents to the platform clipboard and confirms the completed copy. Wide code blocks and tables scroll horizontally rather than compressing their contents past readability. Messages and tool calls retain their first-seen event order as later status events update an existing tool card. Approval rule internals are intentionally not shown.
Approved checkpoints collapse automatically to a summary showing the action and whether it was approved once or allowed for the task. Selecting the summary expands the original details for inspection without restoring decision controls. This applies to generic and website approvals, inside tool cards and standalone in chat, including approvals loaded from history. Pending requests remain open until the server confirms approval; a failed resolution leaves them actionable. While a decision is on its way, the card disables its controls and the chosen one shows progress (Approving…, Allowing…, or Denying…), so a second tap sends nothing.
Every finished message with text has Copy and Select Text (ADR-0122): labelled
under an answer, icon-only under the owner's own messages and at accessibility
text sizes. SwiftUI selection stops at each rendered paragraph, list item, and
table cell, so Copy writes the whole message at once, rendered from the same
Markdown parser as formatted RTF, semantic HTML, and plain text without Markdown
symbols, with no text color so a dark-mode copy never pastes white. Select Text
opens the message in a native read-only text view (NSTextView, UITextView)
where any range, or all of it, can be selected and copied with its formatting;
the view keeps a selection when the transcript redraws. A file the agent exported
arrives as a button under its answer and opens the artifact viewer, which on the
Mac opens at a reading width.
Adjacent successful calls of the same tool, including web.search and
web.fetch, collapse into one count-labelled activity bundle. Expanding it
preserves access to each call's arguments, result, and risk. The bundle shows
the highest individual risk. Fetch bundles also include rejected, unavailable,
and failed calls, with outcome counts in the summary and each call's status
and URL in its expanded row. An error result on a completed fetch is displayed
as failed. Messages, different tools, approvals, queued or running calls,
denials, and uncertain outcomes break the bundle. Other tools bundle only
successful completions.
Artifact metadata and bytes are fetched separately. The process-local content
cache sends If-None-Match and reuses bytes on 304, retains at most 32 MiB,
and evicts least-recently-used values. It is cleared when the app leaves the
foreground, the connection changes, or credentials are forgotten; it never
writes artifact bytes to disk. Artifacts can be previewed or exported through
the operating system file picker. The Mac viewer uses a single-pane sheet with
Close and Download controls; loading a file does not rebuild navigation columns.
Cancelling the save picker keeps the viewer open, and saving preserves the
original bytes, including SVG files whose preview cannot be decoded. The macOS
UI regression loads an SVG from a reply, cancels a download, then saves it and
compares the exported bytes.
The current public SSE contract exposes tool result content and trust but does
not expose every invocation's stored structured_result, effect classification,
or a general tool-detail route. The client therefore renders those richer
specializations when the event payload contains them and otherwise falls back
to the public content/trust view. The server additions for authoritative history
and deletion are limited to the session list and delete routes described by
ADR-0050; no richer tool-detail route was added.
Memory and People¶
Memory opens from the toolbar (on iOS, from the More menu) with two
collections; the server mounts its routes when AGENT_MEMORY_API_ENABLED=1.
Memories lists the principal's governed beliefs with status and
type filters and a flag toggle that shows only memories needing review; every
email-derived memory arrives flagged. A memory's Review menu offers Mark
reviewed, Not true, Not relevant here, and Delete memory, and a row can be
swiped to delete after a confirmation (ADR-0117, under memory.write). A
deleted statement does not form again; its original messages stay at their
source.
People (Milestone 28, under people.read and people.write) lists the
people the owner knows or writes to (ADR-0121), with Pinned and Needs review
collections. Needs review holds people known only by a name or role and each
possible duplicate, answered with Merge or Not the same (ADR-0125). A person's
profile shows names and contact details, relationships, a history timeline
that includes email summaries (ADR-0126), open threads, and facts with Inspect
evidence and correction actions; Manage person holds Repair identity and
Forget person. Import history analyses selected chats or mail accounts under a
date range, record limit and spending cap; see
People operations.
Memory, People and Schedules draw with the A2 tokens. Each list sits on paper with hairline rows. A memory row leads with its kind in small capitals and the day it last changed, then the statement and where it came from, with "Needs review" in amber; the People directory shows initials on a teal wash beside each name and, on the Mac, marks the chosen person with the selected tint; a schedule row leads with a calendar glyph, the name beside its state, the instruction's preview and the cadence with the next run. A memory or a schedule opens as a reading page: the statement, or the schedule's name on the Mac, in the serif face, then its record as titled groups of key-value lines on surface cards, the schedule's instruction on a deep-paper block, and a flagged memory's reason in an amber-edged card. On the Mac the Schedules sheet keeps its index beside the detail column, as the Memory sheet does. The synthesis journal keeps its list with teal text for its actions. A person's profile keeps its sections and actions: Prepare for a conversation is the filled capsule, Pin and Repair identity hairline capsules, Rename and Forget person quiet ones. The Review menu, swipe-to-delete, the Mac collection picker and every People journey keep their controls; only the drawing changed.
Milestone 26 client modes¶
The approved email experience adds Chat and Email presentation modules on iPhone, iPad and Mac, sharing the existing connection, identity, agent, persona and memory. The mode shell preserves Chat streaming, composer and navigation while email uses scoped server-owned projections and versioned drafts. Foreground-only refresh, adaptive layouts, exact-send approval and all existing transport-only client boundaries remain mandatory. Milestone 26's new native evidence is required independently of existing Apple gates.
AppCoordinator retains the Chat and Email view models and the mode shell keeps
both navigation trees mounted. Email uses the Chat connection's authenticated
transport. Its compact navigation opens a thread from the inbox; regular iPad
and Mac layouts show the list beside thread detail. The initial inbox requests
five important threads, with account filtering, search, Other mail, pagination,
per-account freshness and historical coverage. Background updates preserve row
order and announce newly qualifying mail. Opening Email or returning it to the
foreground starts refresh; the client admits a new refresh every sixty seconds
while Email remains visible, and stops admission when hidden or backgrounded.
Transient operation-status failures retry the same operation with bounded
backoff, stopping after three consecutive failures until the next active
refresh. Cancellation and responses from an old connection do not become
visible connection errors. Refresh failures survive successful cached reads
until a refresh completes; a recovered thread read clears its own error while
preserving errors from editing, feedback or send actions.
Messages appear as soon as the thread response arrives, even when an older
server requires a separate draft read. Foreground polling reuses a pending initial
thread read from the same activation instead of replacing it with another request.
Accounts awaiting their first update show a pending message; the client shows
an account failure only when the server has recorded a refresh error.
The inbox keeps search, Important / Other mail and account filtering in a compact
header above the thread list, without a repeated title or introductory copy.
Mac uses a native draggable split divider: the inbox can grow from 280 to 560
points while retaining at least 360 points for reading. Its width and selected
thread survive Chat / Email switching. Compact iPhone navigation and regular
iPad navigation retain their platform layouts.
Rows follow the A2 Email reference: a Handled circle leads each row (empty
while the conversation is in the Inbox, a filled teal check once Gmail has
archived it, the same reversible label change under the same Archive in Gmail
name), then the correspondent in semibold with the date in faint ink, the
subject, Veetbot's one-line gist, and one line of state: an attention state
("Needs reply", "For your attention", "Check send status") carries its reason in
amber, a draft state reads in teal, and a handled, archived or reported
conversation reads in soft ink. The account label appears only when more than
one account is connected. On the Mac the selected row takes the selected
tint with no border; on touch the rows run full width above hairlines. The
Important / Other mail control is a segmented control on the Mac and filter
pills on touch. Full text stays available in the reading column. The five
initial priorities fit without scrolling in a 1200 by 900 Mac window at the
default text size. Larger accessibility text can expand rows naturally.
Thread detail opens with the subject in the display face and "sender · date"
under it, with the Archive in Gmail capsule and the thread menu on the line
above. Veetbot's gist sits behind a two-point teal rule with the state line and
its reason; the messages follow as prose, each under its sender's line and
parted by hairlines, with View in People and Message details kept beside the
sender. A "Teach Veetbot" card on paperDeep opens into the same scoped
feedback (thread, person, kind of content; Important / Less important; reply
need) and reports what was applied with Undo. The reply is "Your reply, drafted
by Veetbot": the envelope and body share one surface box behind a strong
hairline, Review & Send is the filled capsule and Draft options the quiet one.
The bottom bar keeps Discuss in Chat and the reply shortcut on the material
edge. The Email learning, Draft history, Review & Send and unsubscribe sheets
share EmailSheetHeader (a display-face title and a quiet way out); the
Subscriptions census takes the same row and capsule styles.
A compact mailbox-status disclosure retains
per-account freshness; incomplete scans and account failures remain visible in
its collapsed label. Archive guidance lives inside that disclosure and each
checkbox retains its explicit Archive in Gmail label and help. Historical
coverage lives in the scrollable Email learning
pane. Only the active mode contributes toolbar actions. The shared root owns
the Mac window toolbar, independently of the mounted Chat and Email panes.
It presents the active mode's controls in the trailing area so Settings, Memory,
Persona, and Schedules remain visible independently of the sidebar width.
The mounted navigation panes fit the available window height so mode controls
remain reachable in the default Mac window as well as larger windows.
Archive in Gmail is available beside each inbox row and in thread detail on
supported accounts. Checking it removes the row immediately while the server
archives the conversation asynchronously. Normal progress and success stay
silent; a failure or uncertain outcome restores the row with an error. The
client retains confirmed mailbox state, row order and unsaved replies, and
keeps other rows actionable. Pending rows stay hidden through refreshes and
status checks resume when Email reopens. An archived conversation found in
Other mail can be moved back to its originating account's Inbox.
Archiving the open conversation, from its inbox row or the reading pane, clears
the pane immediately and opens the next visible conversation, or the previous one
when archiving the last row. With no remaining rows, the pane becomes empty. Loading the successor does not wait for archive
admission or completion, and a later archive failure does not replace the new selection.
The reading column separates the attention summary, original conversation and reply composer. Why this matters and Improve priorities expand on demand, while the persistent Your reply action reaches the editor without scrolling through the conversation. From and To stay visible; Cc, Bcc and subject expand together and initially open when copy recipients are present. Draft options collect save, history, writing-example endorsement and discard. Review & Send remains the primary composer action and opens a separate, scrollable exact-message review. Native interaction coverage checks the five-row inbox, divider dragging and width preservation across mode changes, the reply shortcut, optional feedback, recipient-field discovery and separation of Chat and Email toolbar actions in light and dark appearance. Email draws on the A2 tokens: paper behind the list and the reading column, teal for the gist rule and draft states, amber for attention, and filled capsules that keep contrasting text.
Thread detail renders text and attachment metadata without remote content.
Feedback distinguishes people, topics, thread importance and reply need, reports
the applied scope and judgment, and supports Undo. Changing the target clears
the previous person or topic value; person feedback requires an explicit choice.
Learning controls expose pause/resume, scoped resets and explicit source
exclusion. Draft edits autosave
with optimistic revisions; conflicts preserve local and server versions, and
draft history can restore earlier text into the editor. Typing continues while a
save is in flight, so Review & Send settles the newest revision through bounded
save attempts before freezing one, and reports a save that will not settle rather
than freezing text the owner cannot see. A failed owner action is reported beside
the control that caused it, and a failed mail read with the conversation it could
not refresh; an action refused far from its button reads as no action at all. The Writing style menu
can explicitly endorse the displayed wording as an example after saving any
edits; a save conflict prevents endorsement. The action binds the current draft
revision and preserves the learning pause state. The learning pane discloses
historical/Sent analysis, shared memories and style, and hosted-model processing.
Coverage counts describe threads retrieved, not completed semantic analysis.
Review & Send verifies
the approval's exact tool/account, provider thread, recipients, subject and body
against the frozen draft before presenting the existing approve-once action.
Because the approval view truncates a string over 512 characters, a reply longer
than that is verified through the published argument_digests entry for that
argument, which proves the whole value rather than the retained prefix; a
truncated argument with no digest stays unverifiable and is refused rather than
presented. The client never invokes Gmail or substitutes a retry send after
uncertainty.
Mail and unsaved edits stay in process memory; there is no durable offline mail
cache or authoritative offline write queue. A connection change, forgotten
credentials, reauthentication failure or denied email read scope clears Email
state. Unsupported servers show the Email capability as unavailable while Chat
remains usable. EmailViewModelTests covers conflict preservation, exact approval
matching, refresh admission, account isolation, cursor and expanded-list
handling, feedback scope and late-response isolation; native UI journeys cover
mode switching, keyboard editing, learning controls and explicit send review.
Run uv run pytest tests/native/test_email_m26.py -q for the three executable
native Email gate checks. This integration-marked bridge invokes real Swift
tests and focused XCTest journeys on Mac, iPhone and iPad, then verifies the
fresh xcresult pass counts with no skipped or expected failures. The iPad lane
also exercises compact navigation through a DEBUG-only launch-time size-class
override; ordinary launches retain the system's size class. Shared-state and
foreground checks also execute the backend's shared-profile/context and bounded
refresh-admission regressions. These checks need full Xcode and both simulator
families, but no database or live mailbox. An unavailable Apple lane is skipped
by pytest and remains an unpassed active gate in the gate report.
Subscriptions¶
Unsubscribe assistance (Milestone 31, plan/email-unsubscribe.md) adds a
Subscriptions surface to Email mode, in its own EmailSubscriptionsViewModel.
The entry appears in the Email toolbar — the shared Mac window toolbar, and the
iPhone and iPad navigation bar — only while an account's projection reports
unsubscribe_supported. The open Mac window updates that entry when accounts
finish loading or the connection is cleared. A server or account that advertises nothing shows no
entry and no thread action, and the client never substitutes another command
for the missing one. Rows come from GET /v1/email/subscriptions in the order
the server returns them, filtered by account and state and paged by cursor;
each row shows the sender, how many conversations arrived in the window, when
the last one did, what the sender offers in plain words, and its state.
Compact iPhone layouts push the detail, while Mac and regular-width iPad show
it beside the list.
Selecting is explicit and bounded. Selection stops at twenty-five, and Select all additionally skips protected senders and any row that is not eligible — unverified, offering no mechanism, or already decided. An unverified row reads Checking… and cannot be selected, while Keep and Report spam stay available on it. Unsubscribe on one row, or Unsubscribe N, opens one confirmation that names every sender with its mechanism — One-click request to example.com or Sends an email to unsub@… — states that an unsubscribe cannot be undone, and offers Also archive existing mail from these senders, off by default. Confirming is the consent and sends exactly one command. The unsubscribe address is never part of a client projection: a target carries a subscription identifier, its evidence digest and its expected revision, and nothing else.
Rows then read Unsubscribing… and settle from the durable operation, polling
GET /v1/email/operations/{id} with the bounded backoff archive rows use
before reading the census back. Success is quiet. A failed run, an unreadable
status, a failed row and an uncertain outcome all restore the row with an
actionable error and offer what remains: try again, Report spam, or Keep. A
still_sending row says so and offers Report spam; a reported_spam row
offers Not spam; Keep is reversible. A bulk conversation whose thread
projection carries a subscription block shows the same Unsubscribe action
beside its sender and opens the same confirmation with one row; each surface
presents only the confirmation it opened.
A conversation the server flags as suspected spam (ADR-0165) carries a
Suspected spam tag in its row and in its detail. The detail explains that
nothing has changed in Gmail and offers Report spam and Not spam; Not spam
posts POST /v1/email/threads/{id}/spam-flag and writes nothing to Gmail.
Every Inbox conversation also offers Report spam in its thread actions. Report
spam posts POST /v1/email/threads/{id}/spam with Archive's optimistic
handling: the row leaves at once, the reading pane advances, and a failed,
uncertain or unreadable outcome restores the row with an actionable error. A
reported conversation reads Reported as spam, offers Not spam, and cannot be
moved back with the Archive checkbox. Without the account's spam_supported
none of these actions appear, and a missing route never falls back to Archive.
The --ui-testing-email-suspected-spam fixture serves a flagged conversation
for the testSuspectedSpamNotSpamClearsTheFlag and
testSuspectedSpamReportSpamRemovesTheConversation journeys, which the native
bridge runs beside the Subscriptions journeys.
Run uv run pytest tests/native/test_email_unsubscribe_native_m31.py -q for the
executable native unsubscribe gate check. This integration-marked bridge runs
the real EmailSubscriptionModelTests, EmailSubscriptionsViewModelTests and
EmailSubscriptionViewStructureTests Swift suites, plus the named spam cases
in EmailArchiveModelTests and EmailViewModelTests, and verifies each case
reported passing. It needs full Xcode but no simulator, database or live
mailbox, and an unavailable Apple lane is skipped by pytest and remains an
unpassed active gate in the gate report.
Approval feedback and deadline outcomes¶
Approval cards retain the first stored resolution when an older pending read
arrives. A resolution event, or a card disappearing from the pending list,
refreshes the stored decision across devices. After a successful decision the
client reads the run state, displaying a completed or failed outcome instead of
leaving a stale wait. Reads cannot update a different selected conversation or
connection. Pending approvals load only for a selected conversation; a late refresh
after starting a new conversation cannot fetch account-wide cards.
Historical cancellation events whose reason is deadline show the
same actionable deadline explanation as new failed runs (ADR-0209). These locally
reconstructed failures carry no diagnostic reference; authoritative server failures
retain their validated references.