Isle has macOS and iOS clients backed by the same in-tree agent and shared personal tools. On macOS, tapping the fn / 🌐 key shows a Dynamic Island-style pill with a focused, minimal text field; the user types a message and presses Enter to send it to the agent (an in-process loop against openai/gpt-6-sol via OpenRouter), and the answer shows in the pill. The pill walks through three phases (IslandPhase) — composing → thinking (the "Thinking" / running-tool row) → responding — and stays open with the answer until the next turn; after an answer the field is focused again so the next question can be typed straight away. fn is a toggle: tapping it again while the pill is visible hides it (the conversation persists for the next open). The conversation persists across turns: each request replays the full history to the model. Reopening within the idle window restores the last answer on screen (the pill comes back showing it, ready for a follow-up rather than blank). After the pill sits closed for 5 minutes the conversation is cleared — both the model-facing history and the on-screen answers — so the next open starts fresh.
Typing is the only input: there is no mode switching. The pill opens with the field focused, Enter sends, and ⌘N clears the conversation and starts a fresh chat without closing the pill.
xcodebuild -project Isle.xcodeproj -scheme Isle -configuration Debug -destination 'platform=macOS' build- Buildxcodebuild test -project Isle.xcodeproj -scheme Isle -destination 'platform=macOS'- Run testsopen $(xcodebuild -project Isle.xcodeproj -scheme Isle -configuration Debug -showBuildSettings | awk -F' = ' '/ BUILT_PRODUCTS_DIR /{d=$2}/ FULL_PRODUCT_NAME /{n=$2}END{print d"/"n}')- Launch the built app./install.sh- Build Debug and install to/Applications/Isle.app(quitting any running copy). Local installs are always the Debug build on purpose:Logcompiles only into Debug, so this is the version that writes session logs to~/Library/Application Support/Isle/logs/. There is no logging Release build — Release ships without it.
IsleTestsholds fast unit tests (AgentEngine.EngineError.classify, the tool bridge).IsleUITestsis still an empty Xcode stub. Run the unit tests with-only-testing:IsleTests(xcodebuild test -project Isle.xcodeproj -scheme Isle -destination 'platform=macOS' -only-testing:IsleTests) — the plain build already typechecks, and a barexcodebuild testneedlessly launches the app-driving UITests target.
- Swift 5, SwiftUI + AppKit + EventKit, deployment target macOS 26.3 (Apple Silicon)
Packages/Robo(local SPM package) — the agent engine: model adapters, the tool loop, thebash/web_searchharness, and an MCP client. A Swift port of theroboportTypeScript framework, kept in-tree- swift-sdk
MCP(SPM) — the tool-declaration vocabulary Isle's own tool surfaces use, and the client behindMCPToolProvider
macOS source is grouped by responsibility under Isle/ (App/, UI/, Input/, Agent/, Tools/, Diagnostics/); the engine lives in Packages/Robo/. Cross-platform Calendar, Reminders, and Fastmail implementations live in Shared/Tools/ and compile into both app targets. New macOS and shared .swift files compile automatically through synchronized file groups; iOS project changes are generated from iOS/project.yml.
App/IsleApp.swift-@mainentry; no real scene (Settings {}), delegates to AppDelegateApp/Preferences.swift- ThePreferencesstruct: one place for every tunable knob (launchAtLogin, idle timeout, theenableLoggingdebug-log toggle, themodel/thinking/systemPromptmodel knobs, theturnTimeoutcap, the tool toggles (enableReminderTools/enableCalendarTools/enableNotesTools/enableMusicTools/enableMailTools/enableBrowserTools/enableMapTools) plus browser knobs (chromeHeadless/chromeDebuggingPort)). Today it's just compile-time defaults for personal use; it's the seam a future settings UI (or aUserDefaults-backed store) hangs off — the values flow one way, from aPreferencesvalue intoAppDelegate→Conversation→AgentEngineApp/AppDelegate.swift- Lifecycle, login-item registration (SMAppService.mainApp, reconciled frompreferences.launchAtLogin), panel placement, show/hide, layout constants, fn wiring, andsubmitTypedText()(the send path).show()makes the panel key so it receives keystrokes (the field needs the caret; ⌘N needs to reach the local monitor). Holds the idle timer (armIdleTimer/cancelIdleTimer): armed onhide(), cancelled onshow(), it clears the whole conversation — the model-facing history (conversation.clearHistory()) and the on-screen answers (state.reset()) — once the pill has stayed closed pastpreferences.idleTimeout(5 min).hide()keeps the answers on screen (state.prepareForClose(), only the in-progress compose is dropped), so reopening before the timer fires takes the restore path:onTogglecallsstate.restore()whenstate.hasHistory, bringing the last answer back as the active turn with the field refocused. Owns the singlePreferencesvalue and injects it intoConversation.
UI/PillPanel.swift- Borderless non-activating floatingNSPanelUI/PillView.swift- The pill UI: emerge/retract animation, the input field (inputField, a borderlessTextFieldbound toIslandState.draft, focused via@FocusState/syncFocus()), the submitted-message echo, the answer bubbles (ResponseBubble), the recent-chats switcher, andIslandState(phase +draft+userMessage+currentTool+assistantTurns)Shared/UI/MarkdownText.swift- Renders answers in both apps as styled markdown (headings, bold/italic, inline + fenced code, bullet/numbered lists, GFM pipe tables, blockquotes, rules). A small line-oriented block splitter (MarkdownParser) defers inline styling toAttributedString(markdown:); no SPM dependency
Input/FnKeyMonitor.swift- Global fn-key tap detection (onToggle) plus a local.keyDownmonitor for ⌘N (onNewChat, swallowed) while Isle holds focus
Agent/Conversation.swift- Owns the model-facing conversationhistoryand the send path:submit(_:)runs a turn throughAgentEngineand surfaces the result through callbacks (onResponse/onNoResponse/onToolEvent).clearHistory()forgets the context (idle timer, ⌘N);loadHistory(_:)replaces it when a saved chat is reinstated. The engine is built lazily on the first turn, not at init — resolving the API key can block on a keychain prompt, and doing that duringapplicationDidFinishLaunchinghangs the app before it draws (and hangsxcodebuild test, since the app is the test host). A secondsubmitcancels the turn in flight rather than racing itAgent/AgentEngine.swift- Wraps theRobopackage: builds the agent (OpenRouter model +Harness.tools()+ Isle's own tools), runs one turn, and returns the answer.EngineErrorreduces any failure to a short line the pill can show, andclassify(_:)mapsModelError/AgentError/cancellation onto it. The turn timeout is a task racing the turn — there's no subprocess to kill, so cancellation unwinds the model stream and any running toolAgent/IsleTools.swift- Bridges Isle's tool providers to the agent in-process. Each still declares its surface as MCPToolvalues (a perfectly good JSON-Schema description, and the SDK is still a dependency for the client);MCPBridge.toolwraps each declaration with a closure that calls the provider's existingcall(name:arguments:)directly.IsleToolSet.Providersholds the optional providers so a disabled preference simply contributes no tools
Shared/Tools/RemindersService.swift- EventKit-backed reminders access on its ownactor(keeps the non-SendableEKEventStoreoff the main thread):list/search/get/create/edit, each callingensureAccess()(requestFullAccessToReminders) first and returningReminderDTOs (neverEKReminder, which isn'tSendable). Dates are ISO 8601 in/outShared/Tools/ReminderTools.swift- The shared MCP tool surface: the fiveTooldeclarations (JSON-Schema fortools/list) andcall(name:arguments:), which dispatches atools/calltoRemindersServiceand packages the result as a single JSON text block (errors becomeisErrorresults with a readable message)Shared/Tools/CalendarService.swift- EventKit-backed calendar access on its ownactor:list_calendarsplus boundedlist_events,get_event,create_event, andedit_event. It requests full event access on first use and returnsCalendarDTO/CalendarEventDTOs, neverEKCalendar/EKEvent; dates are ISO 8601 in/out. Edits to recurring events support this occurrence or all future occurrences; deletion is intentionally not exposed.Shared/Tools/CalendarTools.swift- The shared MCP calendar surface: the five calendar tool declarations and their JSON-Schema/dispatch, mirroringReminderTools. Event creation is immediate; no confirmation layer exists.Shared/Tools/MapService.swift- Shared MapKit/Core Location implementation for Apple Maps place search and ETA summaries.MKLocalSearchresolves places,MKDirections.calculateETA()returns distance/time for driving, walking, transit, or cycling, and a main-actor one-shotCLLocationManagersupplies current location only when requested. Current-location coordinates are never returned to the model.Shared/Tools/MapTools.swift- The shared two-tool Maps surface:search_places(optionally current-location-biased) andestimate_travel_time(named origin or current location, optional departure/arrival date). Both macOS and iOS expose it.Tools/NotesService.swift- AppleScript-backed Apple Notes service on its own actor. It lists/searches/gets notes and folders, and creates, replaces, or appends notes; MCP receives plaintext while writes are converted to escaped HTML internally.Tools/NotesTools.swift- The seven Apple Notes MCP tools:list_note_folders/list_notes/search_notes/get_note/create_note/edit_note/append_to_note.Shared/Tools/MailService.swift-EmailDTO/MailboxDTO, theMailProviderprotocol,MailError, and theMailServiceactor that forwards to the active provider.MailProvideris the seam behind which any backend slots;Preferences.mailBackend(default.fastmail) picks the oneMailServiceis built with (makeMailProvider()).AppleMailProvider(Mail.app, all accounts, offline) is kept as a fallback;JMAPMailProvider(Fastmail, direct API) is the default.MailErrorgained.notConfigured(no token set) and.backend(a JMAP/HTTP failure) alongside the AppleScript casesTools/AppleMailProvider.swift- Drives Mail.app over AppleScript, in-process viaNSAppleScripton a dedicated serial queue (in-process so the Apple Events TCC grant attributes to Isle, not a spawnedosascript— same reasoning as the Reminders grant). Read scripts return a US/RS-delimited (0x1F/0x1E control chars) string parsed back in Swift;get's body is the final field so stray separators inside it survive the bounded split.EmailDTO.idis an opaque base64 of{account, mailbox, numericId}(Mail's own per-account numeric id isn't globally stable).parseRow/literalareinternal(notprivate) soIsleTestscan exercise them without a live Mail.app. Slow by construction — ~10 Apple Events per message (see the header comment) — which is whyJMAPMailProvideris the defaultShared/Tools/JMAPClient.swift- Minimal JMAP transport overURLSession(session discovery + the singlemethodCallsPOST) — the same "speak the protocol directly, bundle nothing" approach asCDPClient. Anactorso the discoveredSession(apiUrl + primary mail account) is fetched once and cached; request/response bodies cross the boundary asData(Sendable), so the provider builds/parses the[String: Any]and no non-Sendable dictionary is passed across. HTTP 401/403 maps toMailError.notConfigured(bad token)Shared/Tools/JMAPMailProvider.swift-MailProviderbacked by Fastmail's JMAP API. A "list 20 headers" is one HTTP request (Email/querychained toEmail/getvia the#idsback-reference) vs. the AppleScript path's ~10 events/message. Uses stable server ids, mailboxroles (special-name → role lookup), real UTC dates. Anactorcaching the mailbox index + sending identity;sendcreates a draft thenEmailSubmission/setwithonSuccessUpdateEmailto move it to Sent and drop$draft(one request).EmailDTO.idis opaque base64 of{accountId, emailId}. Pure parsers (parseEmail/filter/result/resolveMailboxId/…) arenonisolated static(the module defaults toMainActorisolation) soIsleTestsexercises them without a live server. Single-account today (theaccountarg is ignored)Tools/Keychain.swift- A tiny login-keychain generic-password wrapper (Keychain, serviceDestinerLabs.Isle) plusFastmailCredentials, which resolves the JMAP bearer token from theISLE_JMAP_TOKENenv var (dev) else the Keychain — never a committed constant. Sandbox is off, so no keychain-sharing entitlement is neededShared/Tools/MailTools.swift- The shared MCP mail surface: sevenTooldeclarations (search_emails/list_emails/get_email/send_email/create_draft/mark_read/list_mailboxes) andcall(name:arguments:)dispatching toMailService, mirroringReminderTools(single JSON text block,Log.toolchoke point on macOS, errors asisError). macOS exposes the full surface; iOS filters it toreadOnlyToolNamesand rejects write dispatches defensively. macOSsend_emailsends immediately — no confirmation step (prototype-grade, same "misheard command runs with full access" caveat as the unsandboxedbashtool)Tools/CDPClient.swift- A minimal Chrome DevTools Protocol client overURLSessionWebSocketTask(no Node/Puppeteer, nothing bundled — it drives the user's own installed Chrome). CDP is JSON-RPC over WS:send(method:params:)correlates replies by integerid(each with its own timeout), ignores unsolicited events (the higher layer polls instead), and fails all in-flight commands on socket close. Returns rawresultJSON asData(Sendable) so it crosses cleanly intoBrowserService's actorTools/BrowserService.swift- Actor that drives Chrome over CDP:navigate/read/click/type/evaluate/screenshotplusshow/hide, returningPageDTO/ScreenshotDTO. Launches Chrome lazily on first use (it's heavy) against a dedicated persistent profile under Application Support (chrome-profile) with the debugging port open, so automation logins survive across runs without touching the user's everyday Chrome. Headless by default (--headless=new, which shares the same profile).navigateguards against silent no-ops (bad host →errorText; blocked top-leveldata:URLs → final URL unchanged).show/hideflip visibility — Chrome can't switch headless↔headed live, so they relaunch against the same profile, shutting the old one down gracefully via CDPBrowser.close(SIGKILLing the process tree skips Chrome's cookie/localStorage flush and loses the session — a bug found the hard way)Tools/BrowserTools.swift- The MCP browser surface: sevenbrowser_*Tooldeclarations (navigate/read/click/type/evaluate/screenshot/show/hide—show/hideare the human-in-the-loop escalation) andcall(name:arguments:)dispatching toBrowserService, mirroringReminderTools/MailTools(single JSON text block,Log.toolchoke point, errors asisError)
iOS/IsleMobile/ManuscriptView.swift- The document-like mobile conversation UI and inline thinking/tool activity row. It uses the macOS app's black, white, and translucent-white palette; assistant answers use the same sharedMarkdownTextrenderer.iOS/IsleMobile/MobileConversation.swift- Persisted mobile threads plus the streaming Robo session. Builds a tool-enabled session with current date/time context and maps tool events into the inline activity row.iOS/IsleMobile/MobileTools.swift- The iOS tool policy: web search, shared Calendar, Reminders, and Apple Maps tools, plus the read-only subset of Fastmail tools. Mail tools are omitted when no JMAP token is configured.iOS/IsleMobile/Credentials.swift- Device-Keychain storage and one-time environment bootstrap for the OpenRouter key and Fastmail JMAP token.
Diagnostics/Log.swift-Log, the single logging facade everything routes through (model turns, tool calls, conversation turns, lifecycle). Debug-only: the whole path compiles tofalse/no-ops in Release (#if DEBUG) so nothing ships in a distributed build; in Debug it's on by default, andLog.configure(enabled:)(frompreferences.enableLogging) or theISLE_LOGenv var (0/1, wins over the pref) flip it. Writes structured JSONL — one event per line with a shared envelope (ts,level,cat,event, plusconv+turnfor conversation-scoped events) — and mirrors each line toos_log(subsystemDestinerLabs.Isle, category =cat). Holds the "current conversation/turn" context (the pill is single-turn, so it's global; lock-guarded because tool/model events build lines off-main);turnUserlazily opens a conversation,endConversation(viaConversation.clearHistoryon idle-clear) closes itDiagnostics/LogWriter.swift-LogWriter.shared, the serial-queue file appender behindLog. Owns~/Library/Application Support/Isle/logs/and does every write on one privateDispatchQueue(callers hand it finished lines, no locking of their own). Two targets: one always-openapp.jsonlfor app-scoped events, and one<stamp>-<id>.jsonlper conversation for turn/model/tool events (swapped onbeginConversation). Nothing is rotated or deleted
- Background agent:
LSUIElement = YES+setActivationPolicy(.accessory), so no Dock icon or menu bar. Quit via right-click on the pill. - The panel is intentionally taller than the pill (
topRoom/bottomRoomin AppDelegate) so the animation can render outside the pill without being clipped by the window bounds. - fn is a hardware modifier, not a key, so it's detected via
NSEvent.flagsChangedkeyed onkVK_Function— not a Carbon hotkey. - Keyboard routing: the panel is a
.nonactivatingPanelwithcanBecomeKey = true, somakeKeyAndOrderFrontmakes it key (caret + keystrokes) without activating Isle or defocusing the frontmost app (Raycast-style) — the trade-off is that while the pill is open you can't type into the frontmost app. Enter is handled by the field's own key handling (onSubmitText→submitTypedText); the only monitor-level key is ⌘N (new chat), caught by a local.keyDownmonitor that fires when Isle is key and swallows the event. Esc no longer dismisses; the panel'scancelOperationis a no-op that only swallows the default beep. - Conversation as turns: each answer is an
AssistantTurn(stable id) inIslandState.assistantTurns. The last turn is "active" (white, full, scroll-capped) only whilephase == .responding; otherwise it renders as a collapsed two-line greyed context bubble. Because the bubble keeps its identity across that flip, the active→context change animates as a collapse (both states shareResponseBubble; the measured height is animated inonPreferenceChangeso it doesn't jump). The view shows the active turn plus one prior while responding, else just the single prior. The model-facing history (user + assistant text) lives separately inConversationand is replayed to the model each turn — there is no resumable server-side session, so context is carried by us. The active answer renders as markdown (MarkdownText); the collapsed context line strips markdown to a flattened two-line plain-text preview (ResponseBubble.plainPreview) since structure isn't useful at two lines. - The agent engine is in-tree (
Packages/Robo): a local SPM package, a Swift port of theroboportTypeScript framework. It owns the model adapters (OpenAICompatible→OpenAI/OpenRouter), the tool loop, thebash/web_searchharness, and an MCP client. Isle depends on it like any package; it has its own test suite (swift testfromPackages/Robo). Keeping it a package rather than app sources means the engine can be exercised without launching the app — the app is anLSUIElementagent, so it makes a poor test host. - No sandbox on
bash(a deliberate reversal of the Codex-eracomputerAccesstoggle): the oldcodex execpath could hand shell work to Seatbelt (-s workspace-write -a never). A native Swift loop has no equivalent, so thebashtool runs with Isle's full privileges and TCC identity, always. That matches how Isle was actually run (computerAccessdefaulted to on), and the toggle was removed rather than left as a setting that no longer did anything. The trade-off stands: a misheard request runs with the user's reach, including Apple Events to any app. - Isle's own tools run in-process, no MCP server: they used to be reachable only over a localhost Streamable-HTTP MCP server, because MCP was Codex's only mechanism for custom tools. With the loop inside Isle that hop is gone —
MCPServer.swiftand the swift-nio dependency were deleted. The providers still declare their surface as MCPToolvalues (good JSON Schema, and the SDK is still needed for the client), andAgent/IsleTools.swiftbridges each declaration to a direct in-process call.Robo/MCP/MCPToolProviderremains for connecting external MCP servers. - The API key resolves lazily:
OpenRouterCredentialsreadsISLE_OPENROUTER_KEYelse the login keychain (serviceDestinerLabs.Isle, accountopenrouter-api-key). The lookup happens on the first turn, never at launch — a keychain ACL prompt duringapplicationDidFinishLaunchingblocks the app before it draws, and blocksxcodebuild testtoo. Expect one "Isle wants to use your confidential information" prompt on first use; click Always Allow. - Browser automation (native CDP, nothing bundled):
BrowserService/BrowserToolsdrive the user's installed Chrome by speaking CDP over aURLSessionWebSocketTask(CDPClient) — no Puppeteer, no Node, no bundled Chromium. Chrome is launched lazily against a dedicatedchrome-profile(persistent logins, isolated from the user's everyday Chrome) and headless by default. The design point: run invisibly, and only surface a window when a human is actually needed —browser_showescalates to a visible window (login, CAPTCHA, confirm),browser_hidetucks it back. Since Chrome can't toggle headless↔headed live, both relaunch against the same on-disk profile and quit the old instance via CDPBrowser.close(graceful, so cookies/localStorage flush — apkillof the tree loses the session). The headless choice is an app pref, not a per-call tool arg: Chrome is launched once and reused, so it's a launch-time property, not something the model should babysit turn-to-turn.
- App Sandbox is disabled (
ENABLE_APP_SANDBOX = NO). Global keyboard monitoring requires it off; re-enabling breaks the fn trigger. - The app needs Accessibility permission — the global
.flagsChangedtap for fn runs under it. (Isle no longer taps.keyDownglobally, so Input Monitoring is not required; a stale grant from an earlier build is harmless.) The grant requires an app relaunch to take effect. - Reminders needs the
com.apple.security.personal-information.remindersentitlement (a hardened-runtime requirement) plus theNSRemindersFullAccessUsageDescriptionInfo.plist key (set viaINFOPLIST_KEY_…in build settings). First use prompts for Reminders access, attributed to Isle — launch from Finder/open, not a terminal, or the grant misattributes; the grant may need a relaunch. - Calendar needs the
com.apple.security.personal-information.calendarsentitlement plusNSCalendarsFullAccessUsageDescription. Calendar tools callrequestFullAccessToEvents()lazily, so the system prompt appears on their first use; use Finder/openfor correct Isle attribution, and relaunch if macOS asks for it. - Maps current location needs the
com.apple.security.personal-information.locationentitlement plusNSLocationUsageDescriptionon macOS andNSLocationWhenInUseUsageDescriptionon iOS. Place search without a location hint needs no permission; nearby search and current-origin ETA request one location fix lazily on first use. - Mail (AppleScript) needs the
com.apple.security.automation.apple-eventsentitlement (hardened-runtime requirement to send Apple Events) plus theNSAppleEventsUsageDescriptionInfo.plist key. First mail-tool use prompts "Isle wants to control Mail" (Automation) — same launch-from-Finder/relaunch caveats as Reminders. The prompt fires only on the first Apple Event, so it may surface mid-answer the first time the agent calls a mail tool. - TCC ties permissions to the app's code identity (stable: signed
DestinerLabs.Isle, teamHQR74263JL), so a grant survives rebuilds. But launching the dev build from a terminal can misattribute the permission prompt to the terminal — launch from Finder/open(e.g. a copy in/Applications) so prompts attribute to Isle. - The project uses synchronized file groups — new
.swiftfiles inIsle/are compiled automatically. But SPM dependencies still require manualproject.pbxprojedits (no auto-sync for packages).