Using the LocalLM Lab SDK

The SDK developer guide, from docs/sdk-guide.md on the 2.1.0-GA branch (updated 2026-10-16, 9bebe8d). Code and examples: github.com/ancientcomputing/locallm. Overview: LocalLM Lab SDK.

Contents

Audience: a Swift developer linking LocalLMLabSDKCore into their own macOS app to add local-AI tool-calling — system connectors (Calendar, Reminders, Contacts, Location), an MCP client, and (via Components) prebuilt SwiftUI for MCP servers and for the model layer (model picker, open-weight downloads, online-provider settings). Everything here has been exercised against real signed apps and real live MCP servers, not just written from the API surface — see the table below for the working reference apps this guide is drawn from.

Requires macOS 26+ on Apple Silicon, Swift 6 tools.

Status note: this guide describes the API as of 2.1.0. 2.0.0 was a one-time breaking release; every 2.x release after it is source compatible with it (see §9 for the compatibility policy). Coming from 1.x: migrating-to-2.0.md; from 0.8.x: migrating-to-1.0.md first. New in 2.0: per-server trust and tool approval (§7c), MCP Apps (§3f), chat-app support (Building a chat app) and decision models (§6c). Components in particular is newer and smaller than Core.

↑ Top

Start here: run a real example before reading further

This guide's actual on-ramp is the working code under examples/, not the prose below — each one is real, runnable code you can clone and read end to end, not a snippet. These examples are yours to build on: clone one, strip out what you don't need, and ship the rest as part of your own app — they're starting points, not just reading material. Most are real signed .apps (aiql is the most complete: App Sandbox, MCP, the model layer, and the SQL pipeline all in one place); a few (repo-qa, os-matrix, code-buddy, repo-qa-local) are plain command-line tools with no packaging step at all. Pick the one closest to what you're building, then come back to the guide for the parts you want to understand more deeply. Ordered smallest → largest:

If you want to... Start with Lines of code
The SDK's smallest possible footprint — MCP client only, on-device model repo-qa 66
One binary that runs unchanged on both macOS 26 and 27 os-matrix 74
The model layer added to the smallest example — an open-weight MLX model instead of on-device repo-qa-local 92
Prebuilt Components UI instead of writing your own MCP server picker components-demo 141
Core's ready-made connector Tools (Path A) — Calendar/Reminders/Todoist plate-today-tools 149
The model editing files in a user-picked folder, sandboxed workspace-buddy 172
Hand-rolled Tool adapters instead (Path B) — the twin of plate-today-tools plate-today 216

Advanced — skip these on a first pass:

If you want to... Start with Lines of code
Tool authorization's two levers (limited(toMaxImpact:) + ConfirmingToolAuthorizer) end to end security-demo ~250
The model layer running inside App Sandbox, streaming its answer workspace-buddy-local 252
Online providers (GPT / Claude online / OpenRouter) with web search + citations model-switch 283
A CLI coding agent — two models with routing, workspace + host Process tools, MCP code-buddy 298
An MCP dataset pulled through a mechanical SQL pipeline instead of the model copying rows aiql 381
An MLX model shipped pinned to an exact commit, plus checking a small model really called its tool vistanova 931 (whole app)
Decision models (Jev): design and test typed questions on a local MLX model and hosted Jev, then export them for your app (§6c) jevdk ~3,000 (whole app)

Line counts are from annotated-examples.md, which also has the full annotated source for every one of these — every line that touches the SDK marked inline.

↑ Top

Which integration path should I use — this SDK, or the toolkit?

Both are supported, and neither supersedes the other — they solve different problems:

If you're not sure which fits, examples/localai-cli/plate_today.py and this SDK's examples/plate-today are the same "what's on my plate today" feature built both ways — a direct, concrete comparison of what each path actually requires.

↑ Top

1. Linking Core

Reach for this when you've picked the SDK over the toolkit (see above) — this is the one required step before any other code in this guide will compile.

Examples that use it: every example under examples/ links Core this way — plate-today's Package.swift is the simplest single-binary case to copy from; code-buddy and model-switch show the multi-binary shapes below.

LocalLMLabSDKCore ships as a Core.xcframework binary, published as a GitHub Release asset on this repo. Add it to your own Package.swift as a binaryTarget, pointing at the exact release you want:

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "YourApp",
    platforms: [.macOS("26.0")],
    targets: [
        .binaryTarget(
            name: "LocalLMLabSDKCore",
            url: "https://github.com/ancientcomputing/locallm/releases/download/vX.Y.Z/LocalLMLabSDKCore-X.Y.Z.xcframework.zip",
            checksum: "..."  // the .sha256 file alongside the asset on that release
        ),
        .executableTarget(
            name: "YourApp",
            dependencies: ["LocalLMLabSDKCore"]
        )
    ]
)

Get the exact url and checksum for the version you want from this repo's Releases page — each release's assets include both the .xcframework.zip and a matching .sha256 file. examples/plate-today/Package.swift is a real, working example of this same pattern if you want something to copy from directly.

import LocalLMLabSDKCore

If you also want the prebuilt SwiftUI pieces (MCP server picker, OAuth waiting view, resource/ prompt browsing, model picker, AI Models settings panel), add Components the same way — see examples/components-demo for a working example of using it.

The release carries four xcframeworks plus Components as source, all keyed off the same LOCALLM_SDK_VERSION; link only the ones you use: Core (always), Claude (Claude via Foundation Models — forces a macOS 27 target), Inference (local open-weight / MLX models), Remote (online providers — GPT / Claude online / OpenRouter, see §6b), and Components (SwiftUI, consumed as source, not an xcframework). examples/code-buddy (Core + Inference) and examples/model-switch (Remote + Components) are the multi-binary manifest shapes to copy from.

1a. Targeting macOS 26 and macOS 27 from one build

Reach for this when you want one shipping binary to run on both macOS 26 and macOS 27, with the 27-only model families (PCC, open-weight/MLX, online providers) simply absent on 26. Skip this if you're only ever targeting macOS 27.

Examples that use it: os-matrix is exactly this pattern, built to run unchanged on both OS versions — read it end to end for the live version of the snippet below.

As of 1.0.0-beta.2, LocalLMLabSDKCore, LocalLMLabSDKInference, and LocalLMLabSDKComponents all have a macOS 26 deployment floor — and LocalLMLabSDKRemote (added in 1.0.0-beta.3) has a macOS 26 manifest floor too, though RemoteModelProvider itself is 27-only (see §6b). One app, one link, runs on both — with the model families that need macOS 27 (Private Cloud Compute, open-weight / MLX, online providers) simply absent on 26. The #available check is one block, at provider registration; everything after it is identical code.

import LocalLMLabSDKCore
#if canImport(LocalLMLabSDKInference)
import LocalLMLabSDKInference
#endif

@MainActor
func makeLab() -> LocalLMLab {
    var providers: [any ModelProvider] = [SystemModelProvider()]   // Apple on-device — 26 + 27
    if #available(macOS 27, *) {
        providers.append(PCCModelProvider())                       // Private Cloud Compute
        #if canImport(LocalLMLabSDKInference)
        providers.append(MLXModelProvider())                       // open-weight / MLX
        #endif
    }
    return LocalLMLab(configuration: .init(providers: providers))
}

// identical on 26 and 27:
let lab = makeLab()
lab.models.route("chat", to: .system)
let session = try lab.makeSession(route: "chat", tools: myTools, instructions: "…")
let answer = try await session.respond(to: prompt)
↑ Top

2. Required setup before you can use Calendar/Reminders

Reach for this when your app will touch Calendar or Reminders. Skip this whole section if you're not using either connector — Contacts and Location have their own, separate setup (see §7), and MCP OAuth's setup moved to §3, where it belongs alongside the rest of MCP auth.

Examples that use it: plate-today and plate-today-tools need both subsections below (Calendar + Reminders).

Two things are required, not optional extras — skipping either one produces a confusing failure (a silent TCC denial, or a crash on a missing entitlement) rather than a clear error.

2a. Info.plist usage-description strings (if using Calendar/Reminders)

Reach for this when you're about to request Calendar or Reminders access for the first time — add these before your first CalendarAccess/RemindersAccess call, not after a TCC denial.

Examples that use it: plate-today / plate-today-tools.

For every connector you use (Calendar/Reminders shown here — see §7 (Connectors) for the full connector list and its Info.plist/entitlement requirements):

<key>NSCalendarsFullAccessUsageDescription</key>
<string>Your own explanation of why your app needs this.</string>
<key>NSRemindersFullAccessUsageDescription</key>
<string>Your own explanation of why your app needs this.</string>

2b. Entitlements (if using Calendar/Reminders)

Reach for this when you're preparing your app's entitlements file for the first Calendar/Reminders build — required alongside 2a, not instead of it.

Examples that use it: plate-today / plate-today-tools.

<key>com.apple.security.personal-information.calendars</key>
<true/>

There is no separate "reminders" sandbox entitlement — Reminders access (EventKit) is covered by this same calendars entitlement; only the Info.plist NSRemindersFullAccessUsageDescription key and the TCC prompt itself are Reminders-specific. A com.apple.security.personal-information.reminders key isn't a real Apple entitlement — local codesign/pkgutil --check-signature don't catch this, but a real App Store Connect validation (Transporter upload) rejects it outright with "Invalid Code Signing Entitlements." Confirmed live, 2026-08-14.

Both 2a and 2b are required together, on your final signed bundle, or TCC fails in confusing ways. The specific failure sequence (confirmed live, more than once):

  1. Entitlement missing → "Policy disallows prompt" (no dialog appears at all).
  2. Entitlement present, Info.plist key missing → "Refusing authorization request ... without NSCalendarsUsageDescription key".
  3. Both present, but your final codesign call on the outer .app bundle omitted --entitlements → entitlements applied to the inner binary are silently stripped, because signing the outer bundle re-signs its own main executable regardless of any earlier per-binary sign. Sign the binary, then sign the whole bundle with --entitlements again — that second pass is the one that actually matters.
↑ Top

3. Connecting to an MCP server: three auth options, and how to pick between them

Reach for this when your app's pitch is "connect your own tools" — Todoist, GitHub, Linear, an internal MCP server — rather than you hardcoding every integration one by one. MCPServerManager.addServer discovers a server's tools at connect time and hands you back plain descriptors; you decide which become model tools (§6, §7a). The auth handling below is the entire reason connecting isn't a one-liner: a server needs no auth, a static token, or a browser OAuth round-trip, and you usually can't tell which up front.

Examples that use it: plate-today / plate-today-tools (Todoist over OAuth), repo-qa (DeepWiki, .none), components-demo (all three auth types, via Components' MCPServerPickerView).

Adding an MCP server involves one of three auth types, exposed as MCPAuthType. Components' MCPServerPickerView (see §11, the Components package) already builds a UI over all three if you'd rather not build your own — this section explains what each requires, either way.

MCPAuthType Real-world example What your UI must collect
.none Notion, Todoist, Linear-shaped servers Nothing — just the server URL
.pat Static-bearer-token servers (e.g. GitHub-shaped) A text field for the user's token
.oauthManual Servers with no Dynamic Client Registration (e.g. Slack-shaped) A text field for a pre-registered OAuth client ID

OAuth redirect URI — MUST set this yourself (if your server uses OAuth)

Reach for this when any MCP server you'll connect to might use OAuth — set this before your first connect/addServer call, not after one fails.

Examples that use it: plate-today / plate-today-tools (Todoist) and components-demo.

If you connect to any MCP server that uses OAuth (most hosted MCP servers do — e.g. Todoist), you must set MCPOAuthFlow.redirectURI to your own app's URL scheme before calling connect:

MCPOAuthFlow.redirectURI = "yourapp://oauth/callback"

And register that scheme in your Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key><string>com.yourcompany.yourapp.oauth</string>
    <key>CFBundleURLSchemes</key>
    <array><string>yourapp</string></array>
  </dict>
</array>

Do not skip this or reuse the SDK's own default scheme. redirectURI defaults to a placeholder value for source compatibility — if you don't override it, your app's OAuth callback either silently collides with any other app on the same Mac that also failed to override it (macOS resolves a claimed URL scheme to exactly one app, arbitrarily, when more than one registers it), or simply won't route back to your app at all.

Set MCPOAuthFlow.clientMetadataURL in the same spot if you're using CIMD instead of Dynamic Client Registration — see §3d.

Wire the OAuth callback through your AppDelegate, not SwiftUI's .onOpenURL (if your server uses OAuth)

Reach for this when you're wiring the URL-scheme handling for the redirect from the section above — needed the moment you register a custom CFBundleURLSchemes entry.

Examples that use it: plate-today / plate-today-tools and components-demo.

final class AppDelegate: NSObject, NSApplicationDelegate {
    func application(_ application: NSApplication, open urls: [URL]) {
        for url in urls where url.scheme == "yourapp" {
            MCPOAuthRedirectListener.shared.handleRedirect(url)
        }
    }
}

@main
struct YourApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

    var body: some Scene {
        WindowGroup { ContentView() }
            // Without this, WindowGroup ALSO opens a new scene for the same open-URL event
            // AppDelegate already handled above — you'll see a second window pop up when the
            // OAuth browser redirects back. Confirmed live, more than once.
            .handlesExternalEvents(matching: [])
    }
}

If you use SwiftUI's .onOpenURL instead, you will get a second window/scene spawned every time a user completes an OAuth sign-in — WindowGroup treats any open-URL event as a request for a new scene instance unless told otherwise.

It's not "figure out which of the three your target server needs and hardcode it"

.none is a safe default first attempt for any server, not a guess specific to no-auth servers:

So .none → .oauthRegistrationNotSupported → retry with .oauthManual is one reactive flow, not two auth types you have to distinguish up front.

The one case that genuinely can't be auto-detected: PAT. A server that wants a static bearer token and doesn't support OAuth at all just returns a bare 401 with no resource_metadata param — which surfaces as the generic MCPServerError.authorizationRequired, indistinguishable from "this server's OAuth is broken" or any other auth failure. There is no signal in the response that says "I want a PAT." This is real prior knowledge only the target server's own documentation can supply — your app (or your app's user) has to already know this before adding that particular server.

If an OAuth connection fails for a real, unexpected reason (not one of the cases above), MCPServerError.serverError(_:)'s message includes the real HTTP status code, URL, and response body from whichever call in the flow actually failed — e.g. a live rate-limit response comes through as "HTTP 429 from https://.../oauth/register: {\"error\":\"Too many requests...\"}", not an opaque failure with no way to tell what went wrong. Don't swallow or re-wrap this message in your own UI; it's already the most specific information available.

  1. Always attempt addServer(url:displayName:) with authType: .none first, regardless of what the server is.
  2. On .failure(.oauthRegistrationNotSupported), prompt for a manual OAuth client ID and retry with authType: .oauthManual, manualClientID:.
  3. Only offer a PAT text field as a fallback the user opts into deliberately (e.g. "this server needs an access token instead" as a manually-triggered alternative), not as something you try automatically — there's no failure signal that tells you to.

The manual-OAuth-client gotcha: setup instructions must reference your redirect URI

If you write user-facing setup instructions for a Slack-shaped (.oauthManual) server — registering an app and setting a redirect URL on the server's side — that redirect URL must be your app's own scheme (yourapp://oauth/callback, from the OAuth redirect URI section above). Copying another app's setup instructions verbatim into your own documentation would silently misconfigure every user who follows it — their server-side app would try to redirect back into the wrong app (or nowhere) instead of yours.

3a. Which MCP revision the client speaks — and why you mostly don't have to care

Reach for this when you're debugging why a field you expect (structured content, elicitation, icons) isn't showing up, or you're curious what a specific server can do — most integrations never need to read this.

Examples that use it: every example that connects to a real server (repo-qa, plate-today-tools, components-demo) negotiates a revision automatically; none of them reads negotiatedProtocolVersion directly in normal use.

MCP is versioned by date-stamped revisions. The client supports 2025-03-26, 2025-06-18, 2025-11-25 and 2026-07-28 (new in 2.0). You don't pick one. On addServer the client first sends server/discover, the 2026-07-28 way of asking a server which revisions it speaks. A server on the current revision answers, and the connection is stateless. Any older server rejects that unknown method, and the client falls back to the initialize handshake, offering MCPProtocolVersion.clientPreferred (2025-11-25). The two meet at the newest revision both support. Nothing you wrote against the 1.x client changes.

2026-07-28 reworks the wire protocol, and the SDK absorbs all of it:

What the negotiated revision changes is how much a server can hand you:

Revision the server meets you at What you get
2025-03-26 Tools, resources, prompts. Tool results are plain text.
2025-06-18 + structured tool results (MCPToolResult.structuredContent), resource_link results, elicitation, tool titles
2025-11-25 + icons, richer elicitation field types, URL-mode elicitation
2026-07-28 + stateless transport, elicitation via MRTR, live tool/prompt/resource updates

MCPServerState.negotiatedProtocolVersion tells you where a given connection landed (nil until connected; .isStateless is true for 2026-07-28). MCPServerState.liveUpdates is .active(…), .reconnecting or .unavailable(reason) for a stateless server and nil otherwise. You rarely read either. The point of the table is that newer fields on MCPToolResult and the elicitation seam below are simply empty or never called against an older server. They are not errors to guard.

Servers the client refuses. A 2024-11-05 server, or one on the old two-endpoint HTTP+SSE transport, fails addServer with .protocolMismatch. Both were accepted in 1.x. This is the one behaviour change in this area (see migrating-to-2.0.md).

Two knobs on MCPSettings, both rarely needed:

let mcp = MCPSettings(
    versionNegotiation: .auto,   // default. .legacy = never send server/discover (2025 handshake only);
                                 // .pin(.v2026_07_28) = stateless only, no fallback
    liveUpdates: true            // default. false = no subscriptions/listen streams
)
// pass as LocalLMLab.Configuration(…, mcp: mcp). An app without LocalLMLab passes the same
// two arguments to MCPServerManager(versionNegotiation:liveUpdates:).

Use .legacy if a server mishandles the server/discover probe (for example, it hangs instead of rejecting it). The client already falls back on a timeout, so this only saves the wait. Use liveUpdates: false when your app shows a fixed tool list and must not change it mid-session.

3b. What a tool call gives you back: MCPToolResult

Reach for this when you're calling manager.callTool directly instead of going through MCPTool/FileBackedTool (§7a, which already read this for you) — e.g. inside your own hand-written Tool adapter.

Examples that use it: plate-today's TodoistTasksTool calls manager.callTool directly and switches on the result.

manager.callTool(server:tool:arguments:) returns Result<MCPToolResult, MCPServerError>. The MCPServerError side is a transport/protocol failure (unreachable, malformed, auth needed). A tool that ran but reported a problem is not an error — it's .success with isError == true.

switch await manager.callTool(server: state.id, tool: "search", arguments: ["q": .string("mcp")]) {
case .success(let result):
    let forTheModel = result.renderedForModel   // compact rendering sized for a model's context —
                                                // folds in structuredContent, resource links, the
                                                // error flag, and a truncation note. The one line
                                                // you need in most cases.
    result.text                // human-readable text block, if any
    result.structuredContent   // MCPValue? — JSON the server marked as structured data
    result.resourceLinks       // [MCPResourceLink] — uri/title/mimeType pointers, not fetched
    result.isError             // tool signalled failure; renderedForModel prefixes "Error: "
    result.truncated           // response hit MCPResponseLimits and was cut
case .failure(let error):
    // transport/protocol only
}

MCPTool / FileBackedTool (§7a) already call renderedForModel for you — this matters only when you call manager.callTool directly. structuredContent is validated against the tool's declared outputSchema first; a mismatch is turned into isError.

3c. Server-initiated requests: elicitation (and the sampling / roots seams)

Reach for this when a server you connect to might need something from the user mid-call — a missing field, a confirmation — rather than failing the tool call outright. Skip this if every server you use is fully self-contained (no elicitation/create calls).

Examples that use it: none of the SDK's example apps wire elicitation up yet — see the elicitation UI page for it live in LocalLM Lab itself, or MCPElicitationPresenter's doc comment for the Components-side pattern below.

Three MCP features let the server send the client a request mid-operation:

The SDK implements none of them. Each is a handler seam: supply a conforming type or the capability is never advertised and a well-behaved server never asks.

let manager = MCPServerManager(
    handlers: MCPClientHandlers(elicitation: elicitation)   // sampling:, roots:, logging: also here
)

Elicitation is the one to wire, and LocalLMLabSDKComponents ships the whole SwiftUI form:

import LocalLMLabSDKComponents

@StateObject private var elicitation = MCPElicitationPresenter()   // : MCPElicitationHandler

var body: some View {
    MyContent().mcpElicitationSheet(elicitation)                   // attach once, near the root
}

let manager = MCPServerManager(handlers: MCPClientHandlers(elicitation: elicitation))

When a server raises elicitation/create, a sheet appears naming the server, renders a typed, validated form from the requested schema, and returns the user's accept / decline / cancel. See the elicitation UI page.

Try it live, in under a minute, without writing any code — LocalLM Lab itself already wires up this exact form. In the app's MCP Servers panel, Add Server with URL https://example-server.modelcontextprotocol.io/mcp, any name, Auth: None; Add opens a browser to a generic consent screen — approve it. Once connected, enable only the elicitInputs tool (toggle the other eight off — keeps the context budget clean and the model from wandering off to call something else instead), then in Prompt Playground ask it to "Call the elicitInputs tool." The sheet appears with a mix of field types; its header names whatever the server calls itself (≈ "example-servers/everything is asking for input"). Confirmed live, 2026-09-12.

Your own UI or a headless policy: conform to MCPElicitationHandler — one async method on an MCPElicitationRequest (parsed message, typed fields, serverName / serverURL, optional URL-mode url) → MCPElicitationResponse. callTool also takes allowElicitation: Bool = true — pass false where there's no way to show a prompt (a background job) and the request is declined cleanly instead of hanging.

On a 2026-07-28 server the same handlers are reached through MRTR rather than an in-stream request (§3a), and nothing in your code changes. The one visible difference: with allowElicitation: false the client leaves elicitation out of that request's declared capabilities. A server that requires it then fails the call outright (.serverError), where a 2025-11-25 server would have received a decline.

Sampling has a security edge: a registered MCPSamplingHandler lets a server push a prompt through your model. Your handler owns the human-in-the-loop approval; the SDK gives you the seam, not the policy. Don't register it if you don't need it.

3d. CIMD: skipping Dynamic Client Registration

Reach for this when you want a stable, self-described client identity on a server's OAuth consent screen instead of a fresh anonymous registration each time — a production-app upgrade, not a requirement. DCR (the default, no setup) already works for every .none/auto-OAuth server; skip this unless you specifically want CIMD's identity guarantee.

Examples that use it: none of the SDK's example apps set clientMetadataURL — they all run on the zero-config DCR default.

The OAuth flow above relies on Dynamic Client Registration — the server issues a client_id on the fly. Servers that don't support DCR are the .oauthManual path. Client ID Metadata Documents (CIMD) are a newer alternative: your client_id is an HTTPS URL to a small static JSON file describing your app — no per-server registration, no manual ID.

MCPOAuthFlow.clientMetadataURL = URL(string: "https://yourapp.example/mcp/client-metadata.json")

Set it once at startup, next to MCPOAuthFlow.redirectURI. When it's set and the server's auth metadata advertises CIMD, the client registers with it; otherwise it falls back to DCR, then .oauthRegistrationNotSupported. The document's client_id must equal its own URL and its redirect_uris must contain your redirectURI. Optional — DCR stays the zero-config default; CIMD is the upgrade for a production app that wants a stable, self-described identity on the consent screen.

3e. Diagnostics when a user reports an MCP problem

Reach for this when a user hits an MCP connection problem you can't reproduce locally and need them to send you something actionable — not something to wire up proactively for every app.

Examples that use it: none of the SDK's example apps enable the diagnostics buffer; see mcp-diagnostics.md for the full support-flow writeup this section summarizes.

Two logging layers, both strictly off-content (never prompt/response text; bearer tokens and auth codes redacted):

MCPDiagnostics.setEnabled(true)               // ring buffer, default 500 events
MCPDiagnostics.logLevel = .debug              // .debug / .info / .notice / .error / .off
// ... reproduce ...
let report = MCPDiagnostics.exportText()      // hand to support; also .exportJSON()

Gate logLevel on a hidden preference so a normal user runs at .info and you can talk them up to .debug over a support channel; put a "Copy MCP diagnostics" button somewhere unobtrusive.

Full detail — every log category, what MCPDiagnostics.observer gives you, the redaction guarantees, and the end-to-end support flow — is in mcp-diagnostics.md.

3f. MCP Apps: showing a server's interactive views

Reach for this when a server you connect to ships a view with its tools (Todoist's task list, a dashboard) and you want it in your app's conversation.

Examples that use it: mcp-chat.

An MCP App is a small interactive view (HTML, ui:// resource) a server provides alongside a tool. The model never asks for a view: it picks a tool as usual, and if that tool's listing names a view (MCPToolDescriptor.app), your app shows it under the call once it finishes, handed the call's arguments and result. Three pieces:

  1. Say you can show views. Servers only offer them to a client that declares support:

    let lab = LocalLMLab(configuration: .init(
        providers: [...],
        mcp: MCPSettings(handlers: MCPClientHandlers().advertisingMCPApps())))   // LocalLMLabSDKMCPAppsHost
    let session = try lab.makeSession(route: .chat, mcpAppHints: true, authorizer: authorizer)
  2. Render the view for a finished call with LocalLMLabSDKMCPAppsHost (open source, a separate package built on Core's public API):

    import LocalLMLabSDKMCPAppsHost
    
    let cache = MCPAppWidgetCache()
    let resource = try await cache.fetch(resourceURI: record.app!.resourceURI, server: server,
                                         manager: lab.mcp, recordID: record.id)   // checked, cached by hash
    let controller = MCPAppViewController(
        resource: resource,
        tools: lab.mcp.servers[server]?.tools ?? [],
        backend: MCPAppsSessionBackend(session: session, manager: lab.mcp, server: server, instance: record.id),
        configuration: MCPAppsBridgeConfiguration(hostName: "My App", hostVersion: "1.0"),
        actions: .session(session, instance: record.id))   // ui/message, ui/update-model-context → the session
    MCPAppRecreation.deliver(record, to: controller)      // the call's arguments and result
    await controller.load()
    // SwiftUI: MCPAppView(controller: controller).frame(height: controller.contentHeight ?? 360)
  3. Keep many views in one conversation. Each live view is a WebKit process (~100 MB). MCPAppViewPool keeps a few live (limit, default 3) and snapshots the rest (snapshot(for:)) until they're needed again. To show an old call again — after a relaunch, say — MCPAppRecreation.revalidate(record, source:cache:) decides: the current view with the stored result when the tool's output still has the same shape, .needsRefresh when it changed, view-only from the cache when the server can't be reached.

What a view may do. It runs in a sandboxed WKWebView: no network unless your MCPAppsSandboxPolicy allows the hosts it declares, no camera, microphone or location. Its tool calls go through the bridge's MCPAppsBridgePolicy and then — with MCPAppsSessionBackend — through your session's authorizer as ToolCallInitiator.app(instance:), recorded in hostTranscript like the model's (tools the server marks app-only, such as a dashboard's polling, are counted per view instead of recorded). Sending a message as the user (ui/message), sharing context with the model (ui/update-model-context) and opening links are denied unless your policy allows them. Shared context reaches the model with the next respond / streamResponse turn — a turn run on languageModelSession doesn't send it (Running a turn). ConfirmingToolAuthorizer remembers the user's answer per view and tool, so a polling view asks once (destructive calls ask each time).

The sandbox, layer by layer. A view is HTML and script from someone else's server, so MCPAppViewController contains it in layers, each of which holds even if the one above it failed:

  1. CSP, declared ∩ allowed. A server's _meta.ui.csp is a request, not a grant: the CSP actually served on the view's document is each declared origin list intersected with your MCPAppsSandboxPolicy (allowedConnectOrigins, allowedResourceOrigins, allowedFrameOrigins), so a server update can never widen what your app lets a view reach. With the default .closed policy that is default-src 'none', connect-src 'none', frame-src 'none', plus base-uri, form-action and object-src 'none'; inline script and style run, nothing loads from the network. Server-supplied origins are validated strictly (https:// / wss://, a DNS host, optional port) before they go into the header, and baseUriDomains is never granted. Read controller.effectiveCSP for what a view really got.
  2. A network block under the CSP. A WKContentRuleList on the whole web view blocks every http(s), ws(s) and ftp load outside the effective origins — so even a mis-served CSP lets nothing out.
  3. A sandboxed iframe. The view runs in an <iframe sandbox="allow-scripts allow-forms"> inside a small relay page — the spec's arrangement: the view sees a normal window.parent, but runs in an opaque origin with no cookies or storage. allowsSameOrigin: true adds allow-same-origin for a trusted view that needs localStorage; it is off by default.
  4. Navigation pinned. Every navigation — link click, redirect, frame — is cancelled unless it is one of this view's own mcpapp://<token>/ pages. Popups and target=_blank, file pickers, camera and microphone, and alert / confirm / prompt are refused. A link the view wants opened goes through ui/open-link and your bridge policy.
  5. One message path. The native side accepts messages only from the relay page, only JSON-RPC, and every request goes through MCPAppsBridgePolicy (and, for tool calls, your authorizer, as above).

The web view uses a non-persistent data store, so no cookies, cache or storage outlive it. isInspectable: true makes it inspectable in Safari's Develop menu; use it in debug builds only.

↑ Top

4. Keychain storage — automatic isolation, native API, sandbox-safe

You don't reach for this — you get it for free. There is no "set up credential storage" step: MCPServerManager persists OAuth tokens and PATs to the Keychain for you, scoped to your bundle ID, through the native Security API (so it works under App Sandbox and on the Mac App Store). Read this section only if you're auditing what lands in the Keychain, shipping an unbundled CLI (isolation degrades — see the last paragraph), or want to confirm you really don't need to write this yourself.

Examples that rely on it: every MCP example; plate-today's Todoist OAuth is the one that exercises the token round-trip end to end.

MCPOAuthTokenStore/MCPPATStore scope their Keychain storage to Bundle.main.bundleIdentifier automatically — you don't need to do anything for isolation between your app and any other app linking Core on the same Mac.

Both stores also use the native Keychain Services API (SecItemAdd/SecItemCopyMatching/etc.) directly, not a shell-out to a system command-line tool — safe to use from a sandboxed app (including one distributed through the Mac App Store), where shelling out to system binaries is unreliable or outright unavailable. Confirmed live under App Sandbox, not just by code review — see §10 (App Sandbox — building for the Mac App Store).

The one thing to know: if Bundle.main.bundleIdentifier is nil (an unbundled CLI/test target, not a real .app), storage falls back to a fixed, non-isolating string — this is expected and fine for a dev-only CLI, but means a bare unbundled binary shares storage with any other bare binary. Package as a real signed .app before relying on isolation.

↑ Top

5. Walking through a reference app's user experience, step by step

Reach for this when you want the shape of a whole app's flow — launch → request access → fetch/synthesize via the model → show the result → clean up — not just one API's signature. Skip ahead to §6 if you already know the shape and just want the reference.

Examples that use it: plate-today is the app this walkthrough traces line by line; see annotated-examples.md for its full source with every SDK touchpoint marked, if you'd rather see it all at once than in prose.

This section traces examples/plate-today's "what's on my plate today" flow end to end — launch → request Calendar/Reminders/MCP-server access → fetch and synthesize via an on-device model → show the result → clean up. Use this as the template for wiring your own app; the shape is generic, only the tools differ. The actual source is right there in this repo if you want to read or run it directly rather than following along in prose.

This is your first look at tools + connectors — and there's a shortcut. Core gives a model a tool two ways (§7a): Path A — drop a ready-made Tool into the array (GetUpcomingEventsTool(), SearchContactsTool(), MCPTool(descriptor:), …), one line each, with hard-won on-device-model correctness lessons already baked into their descriptions; or Path B — hand-write a Tool struct per connector for full control over its name/schema/description. plate-today (and this walkthrough) is Path B, deliberately: doing it by hand once makes the requestAccess → fetch → return-a-string shape visible. For your own app, reach for Path A first — it's the same permission timing and the same TCC prompts below, with far less code. The matched example plate-today-tools is this exact app rebuilt on Path A — diff the two.

Step 1 — user double-clicks the app icon

Your app's init() should run, before any window exists, as early as possible:

init() {
    MCPOAuthFlow.redirectURI = "yourapp://oauth/callback"
}

This is the single most important line to copy correctly into your own app — the OAuth scheme override from §3's OAuth redirect URI section, set before any connect/addServer call could possibly need it. Your @NSApplicationDelegateAdaptor also installs the OAuth-callback handler (the section right after it) at this point, and .handlesExternalEvents(matching: []) is declared as part of the Scene body that follows — both need to be in place before the window is shown, not bolted on reactively later.

Step 2 — the window appears, showing a spinner

An .onAppear { model.start() } on your root view fires once it appears. A minimal view model's start():

func start() {
    guard case .idle = state else { return }
    state = .fetching
    Task { await fetch() }
}

If state is @Published, the view re-renders immediately to a .fetching case — a ProgressView with something like "Checking your calendar, reminders, and your MCP server…". This is the only user-visible thing that happens synchronously; everything else runs inside the Task kicked off here.

Step 3 — fetch() builds the tool list and starts the model session

First, a hard gate on model availability — if FoundationModels isn't available on this Mac, the flow should end immediately with a failure state, never reaching any permission prompt at all. Assuming it's available, your Tool-conforming structs are instantiated and handed to a LanguageModelSession. No permission has been requested and no network call has been made yet — FoundationModels doesn't invoke a tool's call() until it decides, during generation, that it needs that tool's output.

plate-today builds this list from its own hand-written structs (Path B). The Path A equivalent is let tools: [any Tool] = [GetUpcomingEventsTool(), GetUpcomingRemindersTool(), /* … */] — Core's ready-made tools, no structs of your own to write; everything below (the availability gate, the deferred call(), the permission timing) is identical.

Step 4 — the model calls your Calendar tool, which is where the TCC prompt actually happens

The permission prompt is triggered by exactly one line — inside your hand-written tool's call(arguments:) on Path B, or inside GetUpcomingEventsTool's own call() on Path A (you don't write it, but it fires at the same moment — the first time the model invokes the tool):

let access = await Connectors.requestAccess(.calendar)

Core's Connectors.requestAccess (see §7) handles the no-Info.plist-key and previously-denied cases with clearer errors than calling EventKit directly yourself. The system prompt macOS shows here is only possible because of the entitlement + Info.plist usage string from §2a/2b; without those, this call fails silently rather than prompting (see §2's failure-sequence writeup). If the user denies, a well-behaved tool returns a plain string like "Calendar access not granted." — not an error/throw — so the model receives that as the tool's result and can reason about it in its final summary, rather than the whole request failing. A Reminders tool works identically, one call (.reminders) triggering that separate TCC prompt.

Step 5 — the model calls your MCP tool, which is where the OAuth browser flow happens

This is the one tool that goes through Core instead of a system framework:

let connectResult = await manager.addServer(url: serverURL, displayName: "My Server")

This single call does capability negotiation and auth. If the server has no valid cached token for this app (see §4, Keychain storage — checked under Bundle.main.bundleIdentifier-scoped storage), addServer internally triggers the OAuth authorize flow, which opens the system browser — this is the moment a real browser window appears on the user's screen. The call suspends until either the user completes sign-in (the browser redirects back to your app's own scheme, which your AppDelegate from step 1 catches and forwards to MCPOAuthRedirectListener, resuming this suspended call) or the flow times out.

Once connected, search state.tools for the specific tool your app wants — real servers can expose dozens of tools with similar-sounding names (Todoist's real server exposes 47), so match on the exact tool name, not a loose substring guess. Before calling it, inspect that tool's real JSON schema (tool.rawSchema) rather than assuming its parameters — some tools have defaults that don't match what their name/description implies (e.g. a "due today" query tool whose own default silently also includes overdue items unless you explicitly opt out).

That hand-matching + schema-inspection is Path B for MCP. Path A is MCPTool(descriptor:manager:) — hand it one of state.tools and it builds a working Tool from the live schema, no Arguments struct of your own. repo-qa is that in ~70 lines; see §7a.

callTool hands back an MCPToolResult (§3b) — renderedForModel is the string you feed the model; both adapters call it for you. If your target server pauses a tool call to ask the user something, that's elicitation — wire the one-line Components handler (§3c).

Step 6 — the model synthesizes a summary, the UI shows it

session.respond(to:)'s result becomes your "ready" state, rendered in a scrollable text view. If anything upstream threw instead, show the error — deliberately unpolished in the reference app, since it's meant to be read by developers, not imitated verbatim as production error handling.

Step 7 — user clicks Done

Button("Done") {
    model.cleanUpBeforeQuit()
    NSApplication.shared.terminate(nil)
}

The reference app's cleanUpBeforeQuit() calls manager.removeServer(_:) for any server it connected to — this clears that server's Keychain-stored OAuth token (and any PAT) via the same code path MCPServerManager uses for any server removal, so the next launch starts from a real, un-cached flow again. This is a deliberate choice appropriate for a dev/demo app — a production app would very likely not want to do this, and would instead leave manager.disconnect(_:) or nothing at all, letting the cached token persist across launches the way a normal app's "stay signed in" behavior works.

↑ Top

6. General API reference

This is the MCP client's mechanics — connect, list tools, call a tool, observe state changes, tear down, plus resources and prompts. Use MCPServerManager directly when you're building your own server-management UI or a headless/CLI integration; if you want a ready-made UI over exactly this, Components (§11) wraps all of it. The resources / prompts methods at the end matter only for servers that expose readable content or prompt templates, not just tools.

Examples that use it: repo-qa is the smallest end-to-end use (connect → build tools → run a turn); components-demo additionally exercises resources and prompts.

An app that uses LocalLMLab doesn't make this: it uses lab.mcp, set up with LocalLMLab.Configuration(…, mcp: MCPSettings(…)) (§6a) — makeSession takes MCP tools from lab.mcp, so a second manager would be invisible to it. The calls below are the same on lab.mcp.

let manager = MCPServerManager()  // NOT a singleton — you own the instance

// Connect (discovery happens as part of this call)
let result = await manager.addServer(url: serverURL, displayName: "My Server")
switch result {
case .success(let state):
    print(state.tools.map(\.name))  // what's actually available
case .failure(let error):
    // .authorizationRequired, .oauthRegistrationNotSupported, .httpError(_), etc. — see
    // MCPServerError for the full set and what each implies about whether retrying makes sense.
}

// Call a tool -> Result<MCPToolResult, MCPServerError> ([§3b](#3b-what-a-tool-call-gives-you-back-mcptoolresult)). A tool that ran but failed is
// .success with isError == true, not .failure.
let callResult = await manager.callTool(
    server: state.id, tool: "find-tasks-by-date",
    arguments: ["startDate": .string("today")]
)
if case .success(let result) = callResult { print(result.renderedForModel) }

// React to state changes (servers dict) without SwiftUI/Combine — Core has no UI-framework
// dependency at all
for await servers in manager.serverChanges {
    // ...
}

// Or, if you're in a SwiftUI app and want @Published-style reactivity, Components already ships
// this wrapper — MCPServerManagerObservable, see [§11](#11-components-prebuilt-swiftui-mcp-servers--the-model-layer) (Components) — so you don't need to write it
// yourself unless you want to.

// Post-use
manager.disconnect(state.id)   // keep cached tool list, drop the live connection
manager.removeServer(state.id) // forget it entirely, clear its Keychain entries too

On tool selection: manager.addServer returns everything the server exposes; deciding which tools to actually pass to your AI engine (context-budget management) is entirely up to you — the SDK doesn't filter this for you. Real servers can expose 40+ tools; don't naively pass all of them into a LanguageModelSession without picking the ones your prompt actually needs. See MCPToolDescriptor.estimatedTokens if you want to reason about this quantitatively, or use Components' MCPServerPickerView (§11 — Components), which already builds a per-tool enable/disable UI over exactly this.

On tool-calling from FoundationModels: Core's tools are plain data (MCPToolDescriptor, callTool), not FoundationModels Tool-conforming types — you write a thin adapter per tool, same as step 5 above walks through. This is a design choice, not a limitation: it means your app decides exactly which MCP tool maps to which FoundationModels Tool, with whatever argument-shaping logic that tool's real schema needs — always inspect MCPToolDescriptor.rawSchema for a tool before assuming its parameters.

A real gotcha when writing a Tool that takes no meaningful input: an empty, zero-property @Generable struct Arguments {} is valid and correct — ClockTool in Core is a working example. Don't add a required-but-unused placeholder field as a workaround for "the framework needs some schema." FoundationModels sometimes calls a no-input tool with genuinely empty generated content, and a required field with nothing to satisfy it fails to decode — confirmed live: this produced a GenerationError.decodingFailure in a real tool call, not a hypothetical concern.

On reading GenerationError failures: LanguageModelSession.GenerationError's default NSError bridging is useless — error.localizedDescription (and string-interpolating the error directly) always prints a generic wrapper like "The operation couldn't be completed. (...GenerationError error -1.)" regardless of which case actually fired, and on-device failures sometimes surface an opaque nested com.apple.tokengeneration error with no further detail either. Core's GenerationErrorDescription.describe(_:) async -> String switches on the real case (.guardrailViolation, .decodingFailure, .exceededContextWindowSize, .refusal, etc.) and returns its Context.debugDescription instead, so a guardrail violation reads differently from a context-window overflow instead of both looking identical:

do {
    let response = try await session.respond(to: prompt)
} catch {
    state = .failed(await GenerationErrorDescription.describe(error))
}

Also worth knowing: this generic error -1 sometimes fires as a transient, prompt-independent on-device hiccup — retrying the exact same request can succeed with no other change (see examples/plate-today/README.md's Troubleshooting section for a confirmed live instance). If it persists across several retries, that's no longer this known transient case — use the description above to find out which real GenerationError case is actually firing.

On extracting more than just tools: a connected server can also expose resources (readable content, e.g. a document or dataset) and prompts (server-defined templates). manager. resourcesForSession()/.promptsForSession() list what's currently enabled; manager.readResource(server:uri:)/manager.getPrompt(server:name:arguments:) fetch the real content. Components' MCPResourcesView/MCPPromptsView (§11 — Components) already build a UI over both if you don't want to write your own.

↑ Top

6a. The model layer: local models, routing, sessions

Reach for this when "which model" becomes a real question in your app: you want to offer a locally-run open-weight model and Apple's on-device model and Claude behind one API, let the user (or your own logic) switch between them, keep one warm between turns, and show download/memory state in your UI — without hand-rolling a provider abstraction. If your app only ever uses Apple's on-device model, you don't need any of this — construct a LanguageModelSession directly and pass it Core's tools; the rest of this section is for apps that want more than one model.

Examples that use it: code-buddy exercises all of it — a CLI coding agent with a .heavy and a .light route to locally-run MLX models, Core's Workspace tools, an MCP docs server, and streamed output; run it with a task for one shot, or without one for a >> loop over one persistent LocalLMLabSession (where contextBudget and .contextCompacted earn their place). Each subsection below names which example uses that specific piece.

New in 1.0. Everything above is the MCP client — usable on its own with Apple's SystemLanguageModel and nothing else. The model layer builds on a macOS 26 floor; Private Cloud Compute and open-weight (MLX) models need macOS 27, and Claude needs a macOS-27 target (see §1a).

LocalLMLab — the front door (optional)

Use it when you want one object that wires the model registry, the MCP manager, and the connector/workspace facades together, and hands you a ready session. It's entirely optional — every bare type (MCPServerManager, Connectors, WorkspaceAccess, the providers) stays public and usable without it.

Examples that use it: code-buddy builds LocalLMLab with two providers and maps .heavy/.light before the first turn.

let lab = LocalLMLab(configuration: .init(providers: [
    SystemModelProvider(),
    ClaudeModelProvider(auth: .apiKey(myKeyFromKeychain)),   // from LocalLMLabSDKClaude (macOS 27 target)
    MLXModelProvider(),                                      // from LocalLMLabSDKInference — see below
]))
// To also run on macOS 26, register only SystemModelProvider() unconditionally and the
// rest inside `if #available(macOS 27, *)` — see [§1a](#1a-targeting-macos-26-and-macos-27-from-one-build).

lab.models      // the @Observable ModelRegistry — providers, routes, residency, downloads
lab.mcp         // the app's one MCPServerManager — set up via Configuration(…, mcp: MCPSettings(…)), §3a
lab.workspace   // security-scoped workspace access + ready-made tools
lab.connectors  // Calendar / Reminders / Contacts / Location

An app that uses LocalLMLab uses lab.mcp and never makes an MCPServerManager of its own: makeSession takes MCP tools from lab.mcp, and a second manager would be invisible to it. Configure it with MCPSettings (handlers, response limits, version negotiation, live updates — the same settings MCPServerManager's initializer takes, same defaults); making an MCPServerManager directly is for apps that don't use LocalLMLab.

lab.snapshot() -> LocalLMLabState gives you a Codable snapshot of the route map + residency policy + installed-model records to persist wherever you like (the SDK writes nothing to disk); lab.restore(from:) re-applies one. Use it when you want the user's model choices to survive a relaunch.

ModelProvider and the built-in providers

Use a provider when you're deciding what models your app can offer at all. A provider is "a source of language models" — you register the ones you want, and the registry resolves a ModelID to whichever provider owns its scheme. Online models aren't a separate mechanism — RemoteModelProvider is a ModelProvider like any other; it just gets its scheme from data you supply instead of owning one fixed name (below, and §6b).

Examples that use it: os-matrix registers System, PCC, and MLX behind one #available(macOS 27, *) check (the pattern for supporting both OS floors); code-buddy registers MLXModelProvider + SystemModelProvider as a fallback; model-switch starts with just SystemModelProvider and registers a RemoteModelProvider per configured service at runtime via lab.models.replace(_:), as the user adds each one in Settings.

Provider Ships in scheme macOS For
SystemModelProvider Core system 26+ Apple's on-device model — always there on an Apple-Intelligence Mac
PCCModelProvider Core pcc 27 Apple's Private Cloud Compute model (Apple's own models, not your weights). Needs Apple's managed PCC entitlement, which works only in your app's main executable, signed with a provisioning profile that grants it (§10e) — there is no paid tier. Since 1.0.0-beta.3 the provider maps availability, quota, and typed errors cleanly, and adds probe(timeout:) — an async liveness check (availability(for:) can say .available while turns still fail). Neither is a way to find out whether you have the entitlement: without it FoundationModels traps on the first PCC call, these included, so check your own entitlement first (§10e).
ClaudeModelProvider(auth:) LocalLMLabSDKClaude claude 27 Claude, via a host-supplied API key or App Attest client id — the SDK stores neither
MLXModelProvider Inference mlx 27 Locally-run open-weight models (Qwen, Llama, …) via MLX
try RemoteModelProvider(_:) Remote host-chosen — set in RemoteProviderConfig.scheme, one instance per provider you configure 27 Hosted APIs — OpenAI, Anthropic's Messages API, OpenRouter, any OpenAI-compatible server. The one provider here that's data-driven rather than a fixed named type; details in §6b

LocalLMLabSDKClaude is a third binaryTarget on the SDK release (ClaudeForFoundationModels is macOS-27-pinned, so it can't live in the macOS-26-floored Core); LocalLMLabSDKRemote is a fourth, with no such pin — its manifest floor stays macOS 26, so linking it never forces a 27 deployment target, even though RemoteModelProvider itself only registers successfully on 27.

Within a single macOS-26-floor target, register every 27-only provider you're able to link there — PCC, MLX, Remote alike — inside if #available(macOS 27, *) (§1a). Claude can't join that group at all: linking LocalLMLabSDKClaude forces a macOS 27 deployment target on whatever target links it, so it can't even be conditionally imported into a macOS-26-floor target the way Inference and Remote can — an #available runtime check doesn't help, because the problem is at link/build time, not run time. Registering ClaudeModelProvider at all means doing it in a separate, macOS-27-only target (§1a and os-matrix's README, "Adding Claude").

On macOS 26, lab.models.availability(for:) reports pcc, claude, and mlx as .unavailable(kind: .requiresOS("macOS 27"), …) and lab.models.schemesRequiringNewerOS lists them — this is true for claude even in an app that, like os-matrix, never links LocalLMLabSDKClaude at all (see the ModelAvailability section below for what that scheme reports once the OS floor is actually met but no provider was ever registered for it).

ModelProvider is a protocol — implement it yourself when you have a model source the SDK doesn't ship (a remote inference endpoint, a different local runtime). Its one core requirement is makeSession(for:tools:instructions:transcript:) -> LanguageModelSession — the provider builds the Apple session for its model (this is what lets the model layer run on macOS 26, where Apple's LanguageModel protocol doesn't exist). The registry only ever talks to the protocol.

RouteName + routing — pick a model without hardcoding one

Use routes when you don't want "mlx:mlx-community/Qwen3-8B-4bit" sprinkled through your code. A RouteName (.heavy, .light, .draft, or any string you like) is a name your app maps to a ModelID; you pick a route per session. The SDK owns model residency, never routing policy — your app decides which route a given task uses.

Examples that use it: code-buddy's --route heavy|light flag flips exactly this; --heavy/--light override the model each route points at.

lab.models.route(.heavy, to: ModelID("mlx:mlx-community/Qwen3-8B-4bit")!)
lab.models.route(.light, to: .system)
// later, per task:
let session = try lab.makeSession(route: heavyTask ? .heavy : .light, tools: myTools, instructions: sys)

MLXModelProvider — run open-weight models locally (LocalLMLabSDKInference)

Use it when you want the model to run entirely on the user's Mac with no API key and no network at inference time. It's a DownloadableModelProvider, so on top of the provider basics it adds the runtime lifecycle (validate → download → capability-probe → remove) shown below.

Examples that use it: code-buddy calls validate before its first run, streams download progress, and prints residencyEventStream transitions to stderr (its README has the small-RAM-Mac walkthrough); workspace-buddy-local runs the whole model layer inside App Sandbox.

let mlx = MLXModelProvider(residentModelLimit: 1)   // 1 model resident at a time

// Before you download: is this model sane for this machine? (no weights pulled)
let check = try await mlx.validate("mlx-community/Qwen3-8B-4bit")   // PreflightResult
//   → repo reachable? MLX format? architecture supported? weight size vs this Mac's RAM?
//     each failure names its stage.

// Download, tracking progress
for try await event in mlx.download("mlx-community/Qwen3-8B-4bit") {
    if case .progress(_, _, let fraction) = event { updateBar(fraction) }
    if case .completed(let installed) = event { /* InstalledModel */ }
}

// A "Cancel" button: aborts the real transfer, not just this loop's iteration — the stream
// finishes by throwing (typically CancellationError) instead of yielding .completed. Equivalent
// to cancelling the Task driving the `for try await` above, for callers not structured that way.
mlx.cancelDownload("mlx-community/Qwen3-8B-4bit")

// Post-download smoke test — a real prompt + a trivial tool call. Authoritative for
// that model's ModelCapabilities (some downloaded models can't reliably tool-call).
let report = await mlx.capabilityProbe(installed.id)   // ModelCapabilityReport
// report.capabilities: .toolCalling, .guidedGeneration (respond(to:generating:) — run on
// languageModelSession, see "Running a turn"), .reasoningToggle
// This is authoritative. For a starting shortlist of what tool-calls and what doesn't,
// see docs/tested-models.md — but always confirm your own model with capabilityProbe.

mlx.installed        // [InstalledModel] — what's on disk now
mlx.storageUsed      // total bytes of weights
try mlx.remove(id)   // delete weights

Tool calls on a local model. The MLX runtime parses the model's tool-call markup into calls to your tools. Since 2.1, a call it can't accept fails the turn instead of reaching the user: an unparseable payload, a tool the session doesn't declare, or arguments that break the tool's schema throw LocalLMLabError.generation(underlying:), whose message names the reason (for example malformed_syntax) and never includes the raw model output. Before 2.1 the call markup came back as the model's answer and nothing ran, so if you showed that text, or retried on it, catch the error instead; running the turn again is often enough for a small model. A call written inside the model's reasoning (<think>…</think>) stays inert, since a model rehearsing a call hasn't decided to make it. The exception is a <think> block that opens directly on the call and never closes, which Qwen3 sometimes writes: that call runs. 2.1's runtime also fixes Gemma 4's array and object arguments and adds gpt-oss (Harmony) and Muse Glimmer tool calls; the CHANGELOG has the details.

Where the weights land — a standard Hugging Face cache (models--<org>--<name>/snapshots/<sha>/, deduped blobs/ underneath):

Context Default location
CLI / non-sandboxed app ~/.cache/huggingface/hub/
Sandboxed app (com.apple.security.app-sandbox) ~/Library/Containers/<bundle-id>/Data/Library/Caches/huggingface/hub/

The sandbox redirect is automatic. An 8B-4bit model is ≈4.5 GB and, for a sandboxed app, counts against the app's container. Relocate with MLXModelProvider(cacheDirectory:) or the HF_HUB_CACHE / HF_HOME env vars. A sandboxed app also needs com.apple.security.network.client for the download — examples/workspace-buddy-local runs the whole model layer inside App Sandbox and is the worked example.

The memory story — the whole reason MLXModelProvider isn't just "load and go":

For a production build, pin the repos you ship against: MLXModelProvider(pinnedRevisions: ["mlx-community/Qwen3-8B-4bit": "abc1234…"]) downloads that exact commit instead of tracking main. Without a pin, a repo owner moving main to different weights silently changes what your app fetches on a user's next fresh download — the same file path, a different model. A repo not listed in pinnedRevisions still tracks main as before; pin only the ones your app's behavior depends on being stable.

Supply-chain hardening for a free-text model picker — if your app lets the user type any repo id (rather than shipping a fixed list you've already pinned), pinnedRevisions can't help on its own: there's nothing to pin ahead of time. Three more pieces cover that case, all grouped into one MLXModelProvider.init parameter:

struct MyTrustPolicy: MLXModelTrustPolicy {
    func evaluate(repoID: String) async -> MLXModelTrustDecision {
        repoID.hasPrefix("mlx-community/") ? .allow : .deny(reason: "not on the allow-list")
    }
}

let mlx = MLXModelProvider(supplyChainPolicy: MLXSupplyChainPolicy(
    verification: .enabled,                              // default — see below
    trustPolicy: MyTrustPolicy(),                         // your allow/deny logic; nil = accept anything
    cacheLimits: MLXCacheLimits(maxTotalCacheBytes: 20_000_000_000)))   // refuse past 20GB

Self-pinning after a free-text download: every InstalledModel (from .completed or mlx.installed) carries resolvedRevision, the immutable commit hash the download actually resolved to, whether that came from a pin or from tracking main. Pass an MLXFilePinStore as pinStore: and the SDK persists it for you, outside the Hugging Face cache; see Pinning, updating and cleaning up model versions. If you would rather store it yourself, persist (repoID, resolvedRevision) anywhere that is not under the Hugging Face cache directory (remove(_:) deletes that whole directory, refs and all), and pass it back in pinnedRevisions before redownloading.

Measuring and guarding a local model's turn — usage, speed, memory, limits

Use it when you want to show how a local model performed on a turn (tokens, prompt and generation speed, memory), say why nothing has appeared yet on a long prompt, or decide how many tool rounds one turn may take. New in 2.0; MLX models only — other providers don't report these.

Reference implementation: LocalLM Lab's Playground Benchmark panel reads all of it.

Usage and speed, per turn. Each turn's response carries the model's real token counts and an mlx.* metadata dictionary of turn totals — read it from the response's usage, or the last stream snapshot's:

var last: LanguageModelSession.Usage?
for try await snapshot in session.languageModelSession.streamResponse(to: prompt) { last = snapshot.usage }
let meta = last?.metadata ?? [:]
func number(_ key: String) -> Double? { meta[key].flatMap { Double("\($0)") } }

let tg = number(MLXUsageMetadataKey.tokensPerSecond)          // generation, t/s
let pp = number(MLXUsageMetadataKey.promptTokensPerSecond)    // prompt processing, t/s
let calls = number(MLXUsageMetadataKey.rounds)                // 1 + one per tool round
MLXUsageMetadataKey Value (turn total)
promptTokens, generatedTokens Tokens read and written, summed over the turn's model calls
promptSeconds, generateSeconds Time in each phase
promptTokensPerSecond (pp), tokensPerSecond (tg) Σ tokens ÷ Σ seconds — absent if no time was measured
rounds Model calls: the first, plus one per round after tool calls
stopReason stop, length or cancelled
draftTokensProposed, draftTokensAccepted, speculativeRounds With a speed helper (pairDraftModel) only
residentMemoryBytes, peakMemoryBytes, gpuMemoryLimitBytes MLX memory when the turn began (the loaded weights), its peak during the turn, and how much the GPU may use

Three things to know when reading usage:

What is it reading right now? A long prompt can take tens of seconds before the first token. MLXPrefillMonitor.active lists prompts being processed in this process — exact tokens, which model call of the turn, and since when — from the moment prefill starts until the first output:

if let reading = MLXPrefillMonitor.active.last(where: { $0.repoID == repoID }),
   Date().timeIntervalSince(reading.startedAt) > 3 {
    status = "Reading a \(reading.promptTokens.formatted())-token prompt (model call \(reading.modelCall))"
}

Poll it while a turn has produced no output (once a second is plenty); it's a cheap read.

Guards the SDK applies before every model call.

let mlx = MLXModelProvider(maxModelCallsPerTurn: 40)                        // this app's default
let agent = try lab.makeSession(route: .local, tools: tools,
                                options: .init(maxModelCallsPerTurn: .max))  // this session: no limit

Session beats provider beats 16; values below 1 count as 1.

Pinning, updating and cleaning up model versions

Part of supply-chain hardening for model downloads: the trust policy and verification above decide whether to fetch; pins decide which version you keep.

Why this matters. A Hugging Face repo is not a fixed, vetted artifact. Its owner can change the files behind the same name at any time, anyone can publish a repo with a look-alike name, and a download can be corrupted, oversized, or not runnable on the user's Mac. An app that pulls models at runtime inherits all of that. The pieces above (trust policy, preflight, hash verification, the cache cap) decide whether to fetch a model. Pinning decides which version you keep using, so that the content you validated is the content that runs. examples/mlx-control-room walks through each of these end to end.

A pin is one exact Hugging Face commit hash for a repo. There are two kinds, and they differ in who vouches for the version:

Where it comes from Who approved it May move to
Shipped pin pinnedRevisions in your code (build time) You, the developer Only a full commit hash you name
Captured pin MLXPinStore, written after a user's first successful download The user's own choice (trust on first use) main or a commit hash

A shipped pin always beats a captured one. A shipped pin that can't be fetched (a mismatch, or the commit is gone) fails hard and never falls back to main.

Persist captured pins with MLXFilePinStore. Pass a pin store to the provider and the SDK records (repoID, commit) after each successful download, then uses it on later downloads:

let pins = MLXFilePinStore()   // default file: mlx-pins.json, outside the HF cache
let mlx = MLXModelProvider(
    pinnedRevisions: ["mlx-community/gemma-3-270m-it-4bit": "abc1234…"],   // shipped pins
    pinStore: pins)                                                        // captured pins

Keep the pin file outside the Hugging Face cache. remove(_:) deletes the whole repo directory in the cache, so a pin stored there would vanish along with the weights it exists to protect. Pins are resolved per download, so one captured after init is honored by the same provider instance. effectivePin(for:) reports the pin in force and its source; buildTimePin(for:) reports the shipped one.

Update a pin atomically. Checking costs nothing; updating fetches first and switches last:

// What would change? Downloads nothing.
let check = try await mlx.checkPinUpdate("owner/model", to: nil)   // nil = main (captured pins only)

// Fetch and verify the new commit, then run beforeSwitch, then move the pin, then evict.
for try await event in mlx.updatePin("owner/model", to: newCommit, beforeSwitch: {
    await inference.pauseAndDrain()        // your code: stop new runs, let the current one finish
}) { /* DownloadEvent progress */ }

// Roll back: the old version is still cached, so this is instant.
for try await _ in mlx.updatePin("owner/model", to: oldCommit) {}

The order is preflight at the new commit, fetch and verify, beforeSwitch, move the pin, evict the resident model. If any step fails, the pin does not move and nothing changes. beforeSwitch is where the host pauses inference, because the SDK cannot know what else your app has running against the model. updatePins(_:beforeSwitch:) moves several repos together (a base model and its draft, say) and is all-or-nothing.

Ship a shipped-pin update without a new app release. Only a commit the developer names may move a shipped pin. The SDK never fetches a feed or decides what is "newest"; where your app learns the new commit, and how it authenticates it, is yours. Give the provider managedPinStore: MLXFileManagedPinStore(), then call updatePin(_:to:) with the commit your feed names. The saved override records the shipped pin it was based on, and applies only while that still equals this build's shipped pin. A newer app release therefore always wins over an older runtime update, and the stale override is discarded.

Clean up old versions. An update leaves the previous version on disk, which is what makes rollback instant. The SDK never prunes on its own. It gives the host the inventory:

for snap in mlx.snapshots(for: "owner/model") {
    // snap.revision, snap.isCurrent, exclusive bytes vs bytes shared with other versions
}
let freed = try mlx.removeSnapshot("owner/model", revision: oldCommit)   // refuses the current version

The cache stores each file once and shares it between versions, so removeSnapshot deletes only the files no other version uses. Exclusive bytes is what removing a version would actually free.

Pairing a second model — speed helper and specialization patch (verified live in examples/mlx-control-room, which has a Speed pair and an Adapter pair with a live on/off switch). Two ways to attach a smaller second model to a base model, both set on the provider (not SessionOptions) so they can be flipped on/off at runtime without rebuilding a session:

// Speed helper: base model verifies a small draft model's guesses in bulk instead of
// generating token-by-token. No change in output quality when it helps.
mlx.pairDraftModel(SpeculativeDecodingSpec(draftRepoID: "mlx-community/Qwen3-0.6B-4bit"),
                    with: "mlx-community/Qwen3-8B-4bit")

// Specialization patch: snaps a small LoRA/DoRA adapter onto the base model to specialize its
// style/tone/skill, instead of shipping a whole separate multi-gigabyte model.
mlx.pairAdapter(AdapterSpec(source: .huggingFace(repoID: "stbenjam/qwen3-0.6b-haiku-mlx-lora")),
                 with: "mlx-community/Qwen3-0.6B-bf16")

// Either call with `nil` clears the pairing. Both take effect on the *next* session built for
// that base repo id — safe to call at any time, including mid-run, for live A/B testing.
mlx.pairDraftModel(nil, with: "mlx-community/Qwen3-8B-4bit")

makeSession + LocalLMLabSession — a session with your tools + MCP tools merged

Use it instead of constructing LanguageModelSession yourself when you want the SDK to resolve the route → model, build the model, and assemble the tool list (your tools plus the enabled MCP session tools from lab.mcp, unless includeMCPTools: false).

Examples that use it: code-buddy's whole makeSession(route:tools:instructions:) call, tools = Workspace tools + its host-owned GitTool/RunTestsTool.

let session = try lab.makeSession(
    route: .heavy,
    tools: [SearchWorkspaceTool(), ApplyPatchTool(), myGitTool],
    instructions: "You are a coding assistant.",
)
// Run a turn — nothing here runs an agent loop:
let answer = try await session.respond(to: task)
// or stream it (each element is the reply so far):
for try await partial in session.streamResponse(to: task) { … }

session.route / session.modelID tell you what actually backs it. makeSession is one function, makeSession(route:tools:instructions:restoring:includeMCPTools:mcpAppHints:options:authorizer:):

Run every turn with respond / streamResponse. They are what make the rest of this section work — the MCP tool refresh above, the sampling options below, turnContext, the chat history, the overflow retry. languageModelSession is Apple's session underneath, and a turn run on it directly skips all of that; what it costs, and when it's still the right call (guided generation, reading the raw transcript), is in Running a turn.

options: SessionOptions carries per-call knobs — provider-native web search, sampling (temperature / topP / topK / seed / maxOutputTokens), and effort. effort: .off asks the model not to think: on the MLX tier it flips the chat template's thinking toggle for models that have one (the Qwen3 family). It's a preference, not a requirement — .off never throws: a model whose reasoning can't be suppressed (e.g. DeepSeek-R1, which always reasons) just keeps reasoning instead of failing the turn, same as a model with no thinking toggle at all, which is a no-op either way. If you need to know ahead of time whether .off will actually take effect (to gray out a "hide thinking" toggle in your own UI, say), check ModelCapabilities.reasoningToggle from capabilityProbe(_:) rather than trying .off and inspecting the result — there's no error to inspect, by design. On the remote providers .off maps to the provider default — there is no true "reasoning off" on those APIs, so pass .low to minimise it. aiql uses .off (§8b). .low….max don't reach MLX.

Sampling on Apple's on-device model (system). The SDK maps temperature, maxOutputTokens, topK / topP and seed onto Apple's GenerationOptions and applies them on every session.respond(...). A few rules come from how Apple's API is shaped, and the SDK reports a combination it can't honour with a SessionOptionsError — thrown from makeSession, not discovered on the first turn — instead of quietly dropping a knob:

You can override per turn with session.respond(to: task, options: SessionOptions(temperature: 0)). Per-call values win field by field, and setting topK or topP on the call replaces the session's choice of either. respond(to:options:) is for the on-device model only — every other provider fixes its options at makeSession, so passing non-default options to one throws .perCallOptionsUnsupported rather than being silently ignored.

A seed is best-effort on Apple's model, not a guarantee. In testing, the same seed (and greedy decoding) gave the same text on every call within a running process, but the first request after a process launch occasionally differed, and the model itself can change with an OS update. Treat "same seed → usually the same text" as the contract; don't build tests or caching on exact matches. (On MLX, where the weights are pinned, a seed is a stronger tool for replay and golden-output tests.)

These options are applied by respond / streamResponse. A turn run on session.languageModelSession directly does not get them — pass try options.appleGenerationOptions() yourself (Running a turn).

Running a turn, and when to use languageModelSession

Use respond / streamResponse for every turn. Reach for languageModelSession only for what the SDK has no method for — guided generation, or reading the raw transcript.

Examples that use it: every example built on LocalLMLab runs its turns this way; vistanova also shows the one escape-hatch case, guided generation. (The small examples that don't use LocalLMLab — repo-qa, plate-today, workspace-buddy — make Apple's LanguageModelSession themselves; with no SDK session there is nothing to skip.)

let answer = try await session.respond(to: prompt)                  // the whole reply, a String
for try await soFar in session.streamResponse(to: prompt) {         // the reply so far, a String
    show(soFar)
}

session.languageModelSession is Apple's LanguageModelSession underneath, and you can call respond / streamResponse on it directly. The tools still run through the SDK — so tool approval, tool-call records and tool events work either way — but everything the SDK does around a turn doesn't happen:

session.respond / streamResponse session.languageModelSession.respond / .streamResponse
Tool approval (authorizer:), tool-call records in hostTranscript, .toolCallStarted / .toolCallFinished yes yes — these live in the tools themselves
The user's message in hostTranscript as the turn starts; reply and reasoning when it ends; replyInProgress while streaming yes no message at turn start, no replyInProgress; text only after you call reconcileServerToolArtifacts()
turnContext (e.g. the date) and context an MCP App view shared, sent with the prompt yes no — the model never sees them
MCP tools turned on, or servers added, since the last turn offered not offered — the model session keeps the tools it had
retryOnContextOverflow applied (.contextCompacted) no — the overflow error reaches you
SessionOptions sampling on Apple's on-device model applied no — pass try options.appleGenerationOptions() yourself
session.citations, .serverToolCall (provider web search) updated only after reconcileServerToolArtifacts()
session.cancel() stops the turn doesn't — cancel your own Task

When the escape hatch is the right call:

Coming from Apple's methods: respond returns the reply as a String (Apple: a Response, read .content); streamResponse yields Strings, the reply so far — usually append-only, not across a tool call or when a reasoning model's <think> block gives way to the answer (Apple: snapshots, .content). Failures arrive as LocalLMLabError.generation(underlying:), cancellation as CancellationError; GenerationErrorDescription.describe(_:) reads either. Per-call options are respond(to:options:) — on-device model only, as above.

Building a chat app — hostTranscript, streamResponse, turnContext

Use it when you're drawing a conversation: user bubbles, tool-call rows, the streaming reply, the model's reasoning — and want it to survive a relaunch.

Examples that use it: mcp-chat — the whole chat UI is session.hostTranscript plus streamResponse.

Apple's transcript is the model's context: it can be compacted, and it keeps only what the model saw of a tool result. session.hostTranscript is the conversation as your UI shows it — append-only, in the order the model worked, and @Observable, so a SwiftUI view that reads it redraws as it changes:

session.turnContext = { "[Current date and time: \(Date().formatted())]" }  // sent with every turn,
                                                                         // not shown in the transcript
for try await _ in session.streamResponse(to: text) {}   // the view reads hostTranscript

ForEach(session.hostTranscript.entries) { entry in
    switch entry.content {
    case .user(let text):            UserBubble(text)               // appears as the turn starts
    case .assistant(let text):       Reply(text)                    // the answer only
    case .reasoning(let text):       ReasoningDisclosure(text)      // incl. Qwen3's <think> block, split out
    case .toolCall(let record):      ToolCallRow(record)            // live: .running → .finished
    case .appContext(_, let text):   ViewSharedContext(text)        // an MCP App view told the model this
    @unknown default:                EmptyView()
    }
}
if let reply = session.hostTranscript.replyInProgress { StreamingReply(reply) }  // .reasoning / .text / .isReasoning

Reopening a conversation. Save both halves and restore both:

let shown  = try session.hostTranscript.archive()                         // what the user saw
let memory = try JSONEncoder().encode(session.languageModelSession.transcript)   // the model's memory
// next launch:
let saved   = try JSONDecoder().decode(Transcript.self, from: memory)
let session = try lab.makeSession(route: .chat, restoring: saved)
try session.hostTranscript.restore(from: shown)

Restore only shown to show an old conversation while the model starts fresh. The restored transcript's entries are history: never added to hostTranscript again.

LocalLMLabSession.events — the side-channel Apple's streaming doesn't give you

Use it when your UI needs to show what's happening around generation — a spinner per tool call, a "compacting context…" notice. Token streaming stays on session.streamResponse; events carries only the rest. (A chat UI can also read tool calls live from hostTranscript — next section.)

Examples that use it: code-buddy's → tool / ✓ tool stderr trace is this stream.

for await event in session.events {
    switch event {
    case .toolCallStarted(let id, let name, let arguments):
        showRunning(name, arguments)   // what the model actually sent — can differ from the user's own wording
    case .toolCallFinished(let id, let name, let failed, let resultSummary):
        clearRunning(name, failed: failed, resultSummary: resultSummary)
    case .contextCompacted(let removed):       toast("Trimmed \(removed) old messages")
    case .modelLoadProgress(let fraction):     updateBar(fraction)   // with a local model
    case .routeSwitched(let modelID):          statusLine = "Now on \(modelID)"
    case .serverToolCall(let activity):        showActivity(activity)   // provider-run web search — [§6b](#6b-online-providers--gpt-claude-online-openrouter-locallmlabsdkremote)
    @unknown default: break
    }
}

All six cases, in one place: .toolCallStarted/.toolCallFinished (client tool calls — carrying the tool's real arguments and a best-effort resultSummary/error text, not just id/name/failed, so you can show what the model actually sent a tool without hand-rolling your own wrapper around it), .contextCompacted (the retry-and-trim above), .modelLoadProgress (a local model's first load), .routeSwitched (the route this session was on just repointed to a different model — rare, but real if your app calls lab.models.route(_:to:) mid-session), and .serverToolCall (a provider running web search/fetch on its own infrastructure — §6b). .toolCallStarted / .toolCallFinished arrive however you run the turn; .contextCompacted only from respond / streamResponse (it's their retry), and .serverToolCall once reconcileServerToolArtifacts() has run — which they do for you (Running a turn).

ContextBudget + RetryPolicy — surviving a long session

Use these when your app has long-running sessions (a coding agent, a chat that goes for hours) that will eventually fill the model's context window.

Examples that use it: code-buddy prints contextBudget after each run.

ModelAvailability — gray out a model and say why

Use it when you're building a model picker and need to disable an entry with a reason.

Examples that use it: os-matrix's describe(_:) switches over every ModelAvailability case to print a status line per model family.

lab.models.availability(for: id) (or provider.availability(for:)) returns: .available · .notDownloaded (offer a download button) · .needsCredential (prompt for the API key) · .unavailable(kind:detail:) where kind is machine-readable (.ineligibleHardware, .notEnabled, .modelNotReady, .unsupportedModel, .providerError, .noProvider, .requiresOS(String)) so you can branch without parsing detail. .requiresOS("macOS 27") is what pcc / claude / mlx return on macOS 26. Components' ModelPickerView binds to this directly (disabled "Requires macOS 27" rows).

This works even for a scheme whose provider your app never links or registers at all. availability(for:) recognizes pcc, claude, and mlx as known scheme names and reports their OS requirement from that name alone — it does not require a matching ModelProvider to be registered (or even linked) first. os-matrix shows this concretely: its Package.swift never links LocalLMLabSDKClaude, and its code never constructs a ClaudeModelProvider, yet its availability table still queries claude:sonnet5 and correctly prints requires macOS 27 for it when run on macOS 26 — see the "Why the claude:sonnet5 row appears" note in os-matrix's README.

Once the OS gate is satisfied, the .requiresOS reason goes away — and a different one can take its place. Run that same never-registered claude:sonnet5 query on macOS 27 (confirmed live) and it no longer reports .requiresOS; it reports .unavailable(kind: .noProvider, detail: "No model provider registered for scheme 'claude'") instead — a ModelAvailability.Kind case, not a .requiresOS. .requiresOS only ever means "this scheme's OS floor isn't met yet"; it says nothing about whether a provider for that scheme is actually registered once the floor is met. See the "On macOS 27" section of os-matrix's README for the side-by-side output.

WorkspaceAccess + the Workspace tools — let the model touch files

Use it when the model needs to read or edit files in a folder the user picked. Your app picks the folder and holds its security-scoped access open (§8); WorkspaceAccess keeps every read and write inside that folder. The ready-made tools (SearchWorkspaceTool, WorkspaceTreeTool, ReadWorkspaceFileTool, ReadFileRangeTool, ListWorkspaceFilesTool, ApplyPatchTool, EditWorkspaceFileTool, WriteWorkspaceFileTool, DeleteWorkspaceFileTool) are part of Core, not Apple's: they conform to FoundationModels' Tool protocol, so you pass them straight into makeSession.

Examples that use it: code-buddy offers seven of them (all but ApplyPatchTool and DeleteWorkspaceFileTool); workspace-buddy (Core only, no MLX) and workspace-buddy-local (a local MLX model) offer four: list, read, write and edit.

↑ Top

6b. Online providers — GPT, Claude online, OpenRouter (LocalLMLabSDKRemote)

Reach for this when you want a hosted model API — OpenAI, Anthropic's Messages API, OpenRouter, or any OpenAI-compatible server — behind the same lab.makeSession(route:) call site as the on-device and local models, ideally with the provider running web search for you.

Examples that use it: model-switch is the reference app — add a provider, tick web search, switch models mid-conversation, streamed search activity + citation links, all through Components' AIModelsSettingsView.

LocalLMLabSDKRemote.xcframework is a 4th binary on the release, added the same way as Inference / Claude — a binaryTarget keyed off LOCALLM_SDK_VERSION. It has no third-party dependencies (pure URLSession). Its manifest floor is macOS 26, so linking it does not force a macOS 27 deployment target; RemoteModelProvider itself is @available(macOS 27) and reports .requiresOS("macOS 27") on 26 — so a macOS-26 app can link it and just not register it below 27.

import LocalLMLabSDKRemote

// One data-driven config per provider. A preset fills in the dialect + base URL + auth shape;
// YOU supply the model ids — the SDK ships none (which id is current/good is your call).
var openai = RemoteProviderConfig.openAI(apiKey: key, models: [RemoteModel(id: "gpt-…")])
openai.capabilities.insert(.webSearch)
openai.defaultOptions.webSearch = true              // provider runs the search; no MCP needed

lab.models.replace(try RemoteModelProvider(openai)) // validates, then registers / re-registers
lab.models.route("chat", to: ModelID(scheme: "openai", rest: "gpt-…")!)
let answer = try await lab.makeSession(route: "chat").respond(to: prompt)

RemoteModelProvider.init(_:) always validates config for you — baseURL and auth are just data, and if your UI lets a user type or import them (a custom .openAICompatible endpoint, an imported settings file), that string is an SSRF-shaped primitive inside your app's sandbox that decides where a real API key gets sent. init(_:) runs RemoteProviderConfig.validated() internally and throws RemoteProviderConfig.ValidationIssue for a non-HTTPS baseURL to a non-local host (the key would go over the wire in clear text), a non-http(s) scheme (file:, ftp:, …), a malformed custom header name, or a CR/LF in a credential (header injection) — catch it and reject the input rather than registering the provider anyway. This is a syntax check, not a trust check: it doesn't confirm the host is the one the user meant to talk to, so still warn before sending an existing key to a changed host, and tell users a remote request carries the full prompt, transcript, and tool definitions to that endpoint. If you're certain a config needs to skip this (e.g. a hardcoded corporate-proxy profile where validated()'s rules are known to be wrong for it), use the non-throwing RemoteModelProvider(unchecked:) escape hatch instead — never for anything user-supplied.

Settings UI: Components' AIModelsSettingsView renders the whole "AI Models" panel (add provider + key, per-model rows, web-search toggle, Test connection). Components does not link Remote — it calls back through onSave / onRemove / onTest closures with plain RemoteProviderDraft / ProviderTestOutcome values, so a macOS-26 host app can host the panel and hand the actual RemoteModelProvider work to a 27-only helper. → the full pattern is examples/model-switch.

↑ Top

6c. Decision models (Jev) — lab.decide

Reach for this when your app needs a small, fixed decision about some text — which route, which team, does this need the web, is this sensitive — and you'd otherwise ask a chat model and parse its reply.

New to decision models? They behave nothing like a chatbot. Read Decision models (Jev) in your app first (what they are, for people who know chatbots, with links to TypeSafe's and others' docs), then JevDK's developer's guide for the workflow.

Examples that use it: jevdk.

A decision model (a decider) takes one input and a few typed questions, and answers each with one of the options you allowed plus a probability for every option. It writes no text. TypeSafe's Jev is the original; the SDK uses Jev's vocabulary and asks the same questions of three backends:

Decider Runs Module OS Decision.fidelity
OpenJev (OpenJevDecisionProvider) On the Mac, on an MLX model Inference macOS 27 .tokenScored(calibrated:)
TypeSafe Jev via OpenRouter (JevDecisionProvider(.openRouter(key:))) Hosted Remote macOS 26+ .native
Featherless Simple Jev (.featherless(key:), .featherlessDemo()) Hosted Remote macOS 26+ .tokenScored(calibrated: false)

lab.decide and everything else in this section except OpenJev is in Core and works on macOS 26.

Register a decider and route to it

Deciders are registered and routed separately from chat models (they never appear in a chat model picker), with their own scheme namespace:

import LocalLMLabSDKInference   // OpenJev
import LocalLMLabSDKRemote      // hosted Jev

try lab.models.register(decision: OpenJevDecisionProvider(mlx: mlx))        // the MLXModelProvider you already use
lab.models.route(decision: "support", to: ModelID("openjev:mlx-community/Qwen3-4B-4bit")!)

// or hosted — off until you register one; questions and input are then sent to that service
try lab.models.register(decision: JevDecisionProvider(.openRouter(key: key)))
lab.models.route(decision: "support", to: ModelID("openrouter:typesafe/jev-1.13")!)

ModelRegistry also has replace(decision:), removeDecisionProvider(scheme:), modelID(forDecision:), decisionProvider(for:) and decisionAvailability(for:) (a ModelAvailability, e.g. not downloaded or needs a key). Decision routes are saved by lab.snapshot() like chat routes.

Ask

let d = try await lab.decide(route: "support", state: .text(message), questions: [
    .choice("team", "Which team should handle this message?", criteria: [
        "billing": "payments, invoices and refunds",
        "technical": "bugs, outages and product errors",
        "account": "profile, login and subscription changes",
    ]),
    .noul("refund", "Does the customer explicitly ask for money back?"),
    .score("urgency", "How urgent is it?", criteria: ["can wait", "soon", "now"]),
])

d.choice("team")      // ChoiceAnswer: .choice "technical", .confidence, .probabilities ["billing": …, …]
d.noul("refund")      // Double: probability of yes
d.score("urgency")    // ScoreAnswer: .score 1.6 (expected level, 0-based), .level, .probabilities, .legend
d.fidelity            // .native or .tokenScored(calibrated:)
d.modelID, d.latency, d.usage   // usage: tokens and cost, from hosted backends

Confidence, fidelity and calibration

.native probabilities come from a model trained to make decisions (TypeSafe). .tokenScored ones are read off an ordinary language model — how likely it was to say each option's label — and on a small model they're often 100% even when it's wrong. Don't put tight thresholds on uncalibrated .tokenScored answers.

For a local decider you fit a calibration on your own marked answers and pass it in; fidelity then says .tokenScored(calibrated: true). It holds for one model, revision, wrapper and kind of question, so the SDK ships no fitted values. The usual way is to fit it in JevDK and use the result in your app, with no fitting code of your own:

// 1. In JevDK: mark answers, Calibrate… → Fit calibration, then File → Export Questions….
//    The .decisions.json carries the calibration with the questions it was fitted for.
let set = try DecisionQuestionSet(contentsOf: url)
let openjev = OpenJevDecisionProvider(mlx: mlx, tunedWith: set)       // applies set.tuning?.calibration

// 2. Or copy the snippet (Calibrate window → Copy code): the values plus the system instructions
//    they were fitted with — the calibration only holds with the same wrapper.
let openjev2 = OpenJevDecisionProvider(mlx: mlx,
    wrapper: OpenJevWrapper(system: "…", inputLabel: "…"),
    calibration: DecisionCalibration(noulTemperature: X, choiceTemperature: Y, scoreTemperature: Z))
// X, Y, Z: the Temperature column of JevDK's Calibrate window (yes/no, choice, score; 1.0 = no change).

To fit in code instead (for example from lab.evaluate's samples):

let cal = DecisionCalibration.fit(samples)            // [DecisionCalibration.Sample], from marked answers
cal.measure(samples)                                   // accuracy, expectedCalibrationError, logLoss

Calibration is temperature scaling per question kind: it never changes which answer wins, only how sure it says it is.

OpenJev: the local decider

Hosted Jev

JevProviderConfig presets set the endpoint, scheme, fidelity, limits and model list: .openRouter(key:) (TypeSafe's typesafe/jev-1.13; typesafe/jev-router picks a model for you), .featherless(key:) and .featherlessDemo() (free, rate-limited, no key). validated() applies the chat providers' checks (https, no line breaks in keys). Questions and choice options keep your order on the wire, JSON state is sent as JSON, and d.usage carries the tokens and cost. Like other remote providers, it's off by default and your UI should say where inputs are sent.

A decider and your chat model: one model or two

The pattern the SDK is built for is two models per feature: a decider for the typed choices (does this need the web, which tool, is it sensitive) and a chat model for the text, both on the Mac.

From JevDK to your app: question sets, answer sets, lab.evaluate

Questions are the work, so the SDK carries them from testing to shipping intact:

// The questions exactly as tested in JevDK (File → Export Questions…), with the model, revision,
// wrapper and calibration they were tested with.
let set = try DecisionQuestionSet(contentsOf: Bundle.main.url(forResource: "support.decisions", withExtension: "json")!)
let openjev = OpenJevDecisionProvider(mlx: mlx, tunedWith: set)     // the tested wrapper + calibration
try lab.models.register(decision: openjev)
let decider = set.tuning?.model ?? ModelID("openjev:mlx-community/Qwen3-4B-4bit")!
lab.models.route(decision: "support", to: decider)
if let why = openjev.tuningMismatch(for: decider) { log(why) }      // a different model or revision on this Mac
let d = try await lab.decide(route: "support", state: .text(message), questions: set.questions)

// In CI: score any decider against your answer set (CSV: input, then one column per question id).
let answers = try DecisionAnswerSet(csv: String(contentsOf: answersURL, encoding: .utf8))
let result = try await lab.evaluate(route: "support", questions: set.questions, answerSet: answers)
#expect(result.score("refund")!.accuracy >= 0.95)

Serving decisions over HTTP: jev-serve

To use a local decider from another process or language, run the jev-serve example. It answers hosted Jev's API, OpenRouter's POST /api/alpha/decisions and Featherless's POST /v1/classifier, with OpenJev on this Mac, so existing hosted-Jev client code switches by changing its base URL. It serves the setup you tested in JevDK (File → Export Server Config…): each model pinned to its tested revision (downloaded if missing), with its wrapper and calibration; loopback by default, an optional bearer token. Responses add "fidelity": "tokenScored" and "calibrated", which JevDecisionProvider reads.

The wire format is JevWire (Core), used by both the client (JevDecisionProvider) and the server:

let wire = try JevWire.decodeRequest(body)                          // model?, DecisionRequest (order kept)
try wire.request.validate(against: provider.limits)
let decision = try await provider.decide(wire.request, using: id)
let response = try JevWire.encodeResponse(decision, model: repo, request: wire.request)

Decisions that need your product's knowledge

A decider sees only state and the questions (there's no system prompt), so knowledge it needs, such as which part of your software a bug report concerns and who owns it, has to come through those, or stay in your code:

Don't put the knowledge in an OpenJevWrapper's system text: hosted backends never see it, and a calibration only holds for the wrapper it was fitted with. Full walkthrough with code: JevDK's guide.

Writing questions that work

↑ Top

7. Connectors: Calendar, Reminders, Contacts, Location

Reach for these when your app's value is "the model can see — or change — my calendar, reminders, contacts, or where I am," and you don't want to hand-roll the EventKit / Contacts / CoreLocation permission dance and its error/settings-redirect edge cases. Core gives you one uniform request/status/error lifecycle (Connectors) across all four, plus per-connector read and write methods. Exposing the write methods to a model is entirely your decision — Core enforces nothing beyond the OS's own TCC grant (see the "no built-in gate" note below).

Examples that use them: plate-today reads Calendar + Reminders + Contacts via hand-written Tool adapters (Path B); plate-today-tools is the identical app rebuilt on Core's ready-made connector Tools (Path A) — diff the two. Neither wires the write methods; those ship but aren't demonstrated in an example yet.

Core ships four permission-gated connectors, each wrapping the relevant system framework (EventKit for Calendar/Reminders, Contacts, CoreLocation) with the request/status/error handling already worked out. A unified Connectors facade covers the permission lifecycle (identical in shape across all four); each connector's own type covers its actual data-fetching, which differs per connector.

// Unified: check/request/manage permission for any connector the same way
if !Connectors.isAuthorized(.calendar) {
    let result = await Connectors.requestAccess(.calendar)
    if !result.granted {
        print(result.error ?? "Calendar access denied")
        if result.needsSystemSettings { Connectors.openSystemSettings(for: .calendar) }
        return
    }
}

// Per-connector: actual data, since each connector's data shape is different
let events = CalendarAccess.upcomingEvents(days: 7)
let reminders = await RemindersAccess.upcomingReminders(days: 7)
let contacts = ContactsAccess.search(query: "Jane", limit: 10)
let location = await LocationAccess.shared.currentLocation()

Calendar, Reminders, and Contacts also have write methods. CalendarAccess.addEvent(title: start:end:) creates a new event; .updateEvent/.deleteEvent locate an existing one by its exact title plus the day it's currently on — not an identifier. RemindersAccess.addReminder creates a new reminder; .updateReminder/.deleteReminder (both async) locate an existing one by title, with an optional current due date to disambiguate reminders sharing a title (a reminder may have no due date at all, unlike an event's date, which is required). ContactsAccess. addContact creates a new contact; .updateContact/.deleteContact locate an existing one by given name, with an optional current family name to disambiguate. newPhoneNumbers/newEmails on updateContact, when provided, replace that contact's entire existing list rather than adding to it. Full signatures are in §12 (the full function/type reference) below.

Why name-based lookup, not an identifier (EventSummary/ReminderSummary/ContactSummary still carry eventIdentifier/calendarItemIdentifier/identifier for any consumer that wants the raw token, but update*/delete* no longer take it): two real problems with identifier-based lookup, found the hard way. Some calendar backends (Exchange/Outlook-synced calendars especially) return identifiers long enough to meaningfully bloat an LLM's context just by appearing in a listing; and, separately, a small on-device model calling these as tools proved unreliable at faithfully copying an opaque identifier string across two tool calls. Locating by the same title/name a person actually thinks in terms of fixed both. The one honest cost: for Contacts, name collisions are common enough in a real address book that the "found more than one match" error fires more often than it would for Calendar/Reminders (same-titled events on the same day are rare) — an accepted tradeoff, not a bug; the error tells the caller to add a family name or search first.

If you're building your own model-callable tools on Core, carry forward one naming lesson: keep lookup parameters (onDate, dueDate, familyName, …) and change parameters (newDate, newTime, newFamilyName, …) under clearly distinct names — never a bare date/familyName serving double duty as both "search by" and "change to." A bare shared name was tried first here and shipped a real bug: asked to "change the date," the model put the new date into the field literally named date instead of a separate change field, so the lookup searched on the wrong date and failed.

There is no built-in gate on any of this beyond the TCC grant itself. Unlike LocalLM Lab the product, which has per-connector levels in its Security panel (Read-only, Changes, Full) deciding whether a given session exposes add/update/delete as tools the model can call at all, Core has no built-in level setting — that's UI/IPC behavior specific to that app, not something this SDK provides or enforces. The moment a user grants Calendar/Reminders/Contacts access, every method above (read and write) is callable unconditionally. Whether and how to expose update/delete to a model — as a Tool at all, behind your own confirmation UI, restricted by your own app-level setting — is entirely your design decision as the integrating developer.

Each connector requires its own Info.plist usage-description key and entitlement, same pattern as §2a/2b — see there for Calendar/Reminders; Contacts needs NSContactsUsageDescription + com.apple.security.personal-information.addressbook, Location needs NSLocationUsageDescription + com.apple.security.personal-information.location. examples/plate-today's SearchContactsTool and (build-time opt-in) location/weather tools are working examples of each, including the exact packaging-script changes each one needs — note that the reference app only wires up the read methods; the write methods above are equally available but not currently demonstrated there.

One real side effect worth knowing about: requesting Location access briefly switches your app's own NSApplication.ActivationPolicy to .regular for the duration of the fetch, then restores it — required on-platform for CoreLocation to actually deliver a location fix to a background-style (.accessory) app. If your app deliberately runs with no Dock icon, you'll see that policy flip, briefly, while a location request is in flight. This isn't a bug to work around; it's what makes Location work at all in that configuration.

tccutil reset Location <bundle-id> doesn't work — a real macOS limitation, not specific to this SDK. It fails with "Failed to reset Location approval status." tccutil reset All <bundle-id> is the one command confirmed to actually clear Location along with everything else; to reset only Location, System Settings → Privacy & Security → Location Services → remove the app from the list is the only reliable path found so far. Calendar/Reminders/Contacts all reset individually fine.

Clock and Weather: no permission needed, just drop them in

Reach for ClockTool specifically whenever a session deals in relative dates ("tomorrow", "next week") — no local model has a built-in notion of "now", and pairing it with the Calendar/Reminders tools is the practical fix for their date-grounding caveat above. WeatherTool is the smallest example of a tool needing a network call but no permission grant.

Examples that use them: ClockTool is used by nearly every example; code-buddy and repo-qa include it as a cross-check that tool-calling works at all.

Two more tools ship in Core that aren't part of the Connectors facade above, because they're not permission-gated at all — no entitlement, no Info.plist key, nothing to request:

let tools: [any Tool] = [ClockTool(), WeatherTool(), /* ... */]
let session = LanguageModelSession(tools: tools)

Both accept an optional custom description in their initializer (ClockTool(description: "...")) if you want to override how the model sees the tool — otherwise each falls back to its own defaultDescription.

7a. Two paths to tool-calling: ready-made Tools, or write your own

This is the decision you hit the moment you want a LanguageModelSession to actually call a connector or MCP tool. Path A (Core's ready-made Tools / MCPTool) is the default — drop them in an array, done, with hard-won on-device-model guidance baked into their descriptions. Path B (hand-write the adapter) is for when you need control over a tool's name, schema, or description. They mix freely.

Examples per path: Path A — plate-today-tools (connectors), repo-qa (MCPTool), workspace-buddy / code-buddy (Workspace tools). Path B — plate-today (connectors) and §5 Step 5 (MCP, by hand).

Everything above (CalendarAccess, RemindersAccess, ContactsAccess, LocationAccess, MCPServerManager) is a plain data-access layer — calling CalendarAccess.updateEvent(...) doesn't require FoundationModels at all. Turning one into something a LanguageModelSession can call is a separate decision, and Core gives you two ways to make it:

Path A — ready-made Tools (recommended default). Every connector above also has a ready-to-use Tool-conforming wrapper in Core, same one-line drop-in as ClockTool/WeatherTool:

let tools: [any Tool] = [
    ClockTool(),
    GetUpcomingEventsTool(), AddCalendarEventTool(),
    GetUpcomingRemindersTool(), AddReminderTool(),
    SearchContactsTool(), ListContactsTool(),
    GetCurrentLocationTool(),
]
let session = LanguageModelSession(tools: tools)

The mutating ones — UpdateCalendarEventTool/DeleteCalendarEventTool, UpdateReminderTool/DeleteReminderTool, AddContactTool/UpdateContactTool/ DeleteContactTool — take no permission gate of their own (no per-connector level, matching this section's already-stated SDK philosophy: that decision is entirely yours). Whether to expose them at all is a plain array-membership choice — include them in tools when your UI is ready to let the model delete something, don't when it isn't.

These wrappers exist because real, observed on-device model failures shaped their design, and that guidance is baked in verbatim rather than left for you to rediscover independently:

Path B — write your own adapter, exactly as §5 Step 4 walks through by hand: call CalendarAccess/RemindersAccess/ContactsAccess/LocationAccess directly, choose your own tool names, schemas, and descriptions. Full control, but you're on your own for the pitfalls above. Both paths coexist — Path A is a thin wrapper over Path B, not a replacement for it, so mixing (ready-made Calendar tools alongside a hand-written Contacts adapter, say) is fine.

MCP gets the same two paths. §5 Step 5 is Path B for MCP: match a tool by name out of state.tools, inspect its rawSchema yourself, hand-write a matching @Generable Arguments struct. MCPTool is Path A — it builds a Tool at runtime directly from an MCPToolDescriptor, no Arguments struct required:

let descriptor = state.tools.first { $0.name == "search_issues" }!
let tool = try MCPTool(descriptor: descriptor, manager: manager)
let session = LanguageModelSession(tools: [tool])

It works by turning the tool's real JSON Schema (rawSchema) into a FoundationModels DynamicGenerationSchema at init time — the common, well-behaved subset real MCP servers emit (object/properties/required, array/items, string/number/integer/boolean, string enums) converts cleanly; anything past that (oneOf/anyOf unions, $ref, const, regex pattern) degrades to a free-form string leaf rather than failing the whole tool, since the remote server is still the real source of argument validation. MCPTool(descriptor:manager:) is a throwing initializer — call it per-tool inside a loop and skip (or fall back to a hand-written Path B adapter for) any tool whose schema doesn't build, rather than letting one malformed tool take down your whole list.

7c. Tool authorization: two levers — which tools, and whether they ask first

Reach for this when a session has any mutating tool and you want a real answer to "could this call happen by mistake" — a model mistake, a prompt-injection string in a document it's summarizing, or an MCP server whose tools you didn't write. Skip it if every tool in your session is read-only, or your app has no untrusted input path into the model at all.

Examples that use it: security-demo is the runnable version of everything below — a "Security" panel wiring both levers against Calendar + Todoist MCP.

A model with a mutating tool will sometimes call it wrongly: a model mistake, a prompt-injection string in a document it's summarizing, or an MCP server whose tools you didn't write. Core gives you two independent levers over that.

Lever 1 — selection. Which tools are in the session at all. Not just on/off: ToolImpact (.read < .mutate < .destructive) is a ceiling.

let tools = (calendarTools + workspaceTools).limited(toMaxImpact: .mutate)   // no delete tools

Sequence<any Tool>.limited(toMaxImpact:) drops every tool above the ceiling (an unrated tool counts as .mutate — never .read). It's a plain filter you apply before makeSession, so it works on any runtime — a bare LanguageModelSession, a headless service, macOS 26. This is how you offer a "Read-only / Changes / Full" choice per capability. Every ready-made SDK tool declares its impact (ImpactRatedTool).

Lever 2 — invocation. Whether an in-list tool actually runs this call. ToolCallAuthorizer is the checkpoint between "the model chose to call this" and "the side effect happens." It's opt-in: with no authorizer:, makeSession installs no invocation gate — every tool in the list is callable by the model, with whatever arguments it chooses, and every call runs immediately with no confirmation and no way to deny it.

The SDK's one authorizer is ConfirmingToolAuthorizer. You say who may approve what where the tools come from — per MCP server on lab.mcp, and for your own tools on the authorizer — and it applies that on every call (new in 2.0):

// Per MCP server, on lab.mcp. The server is the unit of trust.
lab.mcp.setTrust(.trusted, server: companyServer)        // believe its read-only / destructive labels
lab.mcp.setToolApproval(.allow, server: companyServer)   // its tools run without asking
// An untrusted server (the default) asks the user before every call.

let session = try lab.makeSession(
    route: "chat",
    tools: tools,                                         // your own tools
    authorizer: ConfirmingToolAuthorizer(
        channel: presenter,                               // how the user is asked
        policy: { call in await myPolicyService.decide(call) },   // optional external function
        hostTools: .ask(atOrAbove: .mutate, by: .user)))  // your own tools' approval (the default)

The settings at a glance (per server in 2.0; a tool has only on/off for now):

Level Setting Options What it decides
Server Trust — setTrust(_:server:) .untrusted (default) · .trusted Whether the server's own labels (readOnlyHint, destructiveHint) are believed. Untrusted: every tool counts as one that may change data (.mutate).
Server Approval — setToolApproval(_:server:) .allow — nothing asks
.ask(atOrAbove: .mutate, …) — reads run, changes and deletions ask
.ask(atOrAbove: .destructive, …) — only deletions ask
.ask(atOrAbove: .read, …) — every call asks
nil — the default for the trust level: untrusted asks before changes, trusted before deletions
Which calls need authorization before they run.
Server ↳ who answers an ask — the by: of .ask .user — the confirmation channel · .policy — your ToolPolicy (allow / deny / ask the user) Who authorizes the calls that need it.
Tool On / Off — setToolEnabled(server:tool:enabled:) on · off (new tools start off) Whether the model is offered the tool at all.
Tool Approval — a later release its own .allow / .ask, overriding the server's e.g. allow find-tasks on an untrusted server, or always ask before delete-object on a trusted one.

The authorizer is a floor, not a ceiling: it can't grant access to a tool that isn't in the list, and it doesn't restrain your own code calling CalendarAccess.deleteEvent(...) directly. It gates model- and view-initiated calls only.

Across processes. If your app runs the session in a headless helper and the UI in another process, implement ToolConfirmationChannel with your transport — that's the only security code you write. PendingToolCall.summary is a Codable PendingToolCallSummary (toolName / argumentsDescription / origin / impact / initiator / serverApproval; drops the non-serializable arguments) built for exactly that hop; DecisionGate (Core) handles the resume-once race between the reply and a cancellation. If the MCP connections live in the other process, record each server's approval on the helper's lab.mcp before makeSession — approvals are keyed by server id and need no connection.

What the SDK can see inside an MCP tool. It didn't write the tool, so on an untrusted server every call is .mutate: limited(toMaxImpact:) can only include or exclude a whole server, and the default approval asks even before a read-shaped call. Trust the server and its own readOnlyHint / destructiveHint set each tool's impact. With makeSession(includeMCPTools: true) the SDK tags the tools with their server (.mcp origin), which is how the server's approval applies. A host that builds its own MCP-backed Tool type (e.g. one proxying to a connection in another process) conforms it to OriginTaggedTool to keep that origin.

Coming from 1.x. RuleBasedToolAuthorizer, ConfirmingToolAuthorizer's requirement: closure and setTrustsToolAnnotations are gone — see migrating-to-2.0.md §3 for the mapping.

↑ Top

8. Filesystem access: security-scoped bookmarks (example, not in Core)

You hit this when you want the model to read or edit files in a folder the user picks — a coding assistant, a "summarize this project" tool, a notes agent. Two halves: getting a usable URL that survives relaunch under sandboxing (this section — copy-paste example code, not a Core API) and then acting on it (§8a — that half is Core: WorkspaceAccess + the Workspace Tools).

Examples: workspace-buddy is the sandboxed folder picker + WorkspaceTools end to end; code-buddy skips the picker (a CLI passes a path) and goes straight to the Workspace tools.

Unlike the four connectors above, filesystem access to a user-picked file or folder is not part of Core, and isn't planned to be. The reason is structural, not an oversight: the actual picker UI (NSOpenPanel) has to live in your app — Core has no UI of its own, by design, same as the MCP server connect/auth UI in §3 (MCP auth options). There's no meaningful "unified API" to offer here the way there is for the four TCC-gated connectors, since the picker itself is host-app UI, not something a library call can produce.

What Core can't help you avoid is the trickier part once a folder is picked: under sandboxing, a plain absolute path stops being usable the moment your app restarts — you need a security-scoped bookmark, resolved and re-accessed explicitly on every launch. Below is a complete, working example (not shipped as SDK code — copy what you need):

import AppKit
import Foundation

enum FolderAccess {
    // Call from a "Choose Folder…" button. Persists a security-scoped bookmark to UserDefaults so
    // access survives app relaunches — plain path strings don't, once your app is sandboxed.
    @MainActor
    static func pickFolder() -> URL? {
        let panel = NSOpenPanel()
        panel.canChooseDirectories = true
        panel.canChooseFiles = false
        panel.allowsMultipleSelection = false
        guard panel.runModal() == .OK, let url = panel.url else { return nil }

        guard let bookmark = try? url.bookmarkData(
            options: .withSecurityScope,
            includingResourceValuesForKeys: nil,
            relativeTo: nil
        ) else { return nil }
        UserDefaults.standard.set(bookmark, forKey: "folderBookmark")
        return url
    }

    // Call before every use of the folder, including right after pickFolder() — resolving a
    // bookmark doesn't grant access by itself; you must bracket actual use between
    // startAccessingSecurityScopedResource() and stopAccessingSecurityScopedResource().
    static func resolveBookmarkedFolder() -> URL? {
        guard let bookmark = UserDefaults.standard.data(forKey: "folderBookmark") else { return nil }
        var isStale = false
        guard let url = try? URL(
            resolvingBookmarkData: bookmark,
            options: .withSecurityScope,
            relativeTo: nil,
            bookmarkDataIsStale: &isStale
        ) else { return nil }

        if isStale {
            // The bookmark still resolves, but macOS wants a fresh one recorded (e.g. the
            // volume was renamed) — re-derive and persist it now rather than letting it go
            // stale again on the next launch.
            if let refreshed = try? url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) {
                UserDefaults.standard.set(refreshed, forKey: "folderBookmark")
            }
        }
        return url
    }

    // Bracket every actual read/write with this, even immediately after pickFolder() — holding
    // scoped access open longer than needed is the usual mistake; grab it, do the work, release.
    static func withFolderAccess<T>(_ body: (URL) throws -> T) rethrows -> T? {
        guard let url = resolveBookmarkedFolder() else { return nil }
        guard url.startAccessingSecurityScopedResource() else { return nil }
        defer { url.stopAccessingSecurityScopedResource() }
        return try body(url)
    }
}

Required entitlements for this to work under App Sandbox:

<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>

8a. WorkspaceAccess/WorkspaceTools: what Core gives you once you have that URL

Reach for this once you have a resolved root URL from the bookmark pattern above — this is the actual read/write API, the Core half of filesystem access.

Examples that use it: workspace-buddy and code-buddy both use these tools; workspace-buddy's withFolderAccessAsync<T>(_:) is the async-aware access wrapper the gotcha below points to.

Once you have a resolved, access-bracketed root URL from the pattern above, WorkspaceAccess — an ordinary Core type, not a permission-gated connector — is what actually reads and writes inside it: listFiles/readFile/writeFile/editFile/deleteFile (plus search, tree, readFileRange, applyPatch). No requestAccess() here — there's no OS dialog for this, the picker is the consent, entirely on your side.

Every operation is async throws (2.0): it throws WorkspaceAccess.WorkspaceError (its message is written for the model) or CancellationError, and runs off your actor, so calling it from the main actor doesn't block the UI:

do {
    let text = try await WorkspaceAccess.readFile(in: root, path: "README.md")
    try await WorkspaceAccess.editFile(in: root, path: "README.md",
                                       oldString: "v1.0", newString: "v2.0", replaceAll: false)
} catch let error as WorkspaceAccess.WorkspaceError {
    print(error.message)
}

How it stays inside the folder. A path is resolved once, to real (symlink-free) components under the root. From there every file is reached by descriptor: the root is opened, each folder below it is opened with openat(… O_NOFOLLOW), and the file is checked through its own descriptor. A symlink that points outside the folder is refused, and so is one swapped in mid-operation — the race the 1.x path-based checks left open. A symlink whose target stays inside the folder still works for readFile/writeFile/editFile; search, tree and listFiles never follow symlinks. Writes go to a temp file that is renamed into place (a create never overwrites a file that appeared in the meantime), editFile keeps the file's permissions and refuses to commit if the file changed while it was being edited, and deleteFile removes single files only — never a folder, and for a symlink, the link rather than its target. What this can't stop: another local process that can write to the folder changing a file's contents between two of your calls. Treat the folder as a trust boundary shared with everything that can write to it.

How much work one call can do. WorkspaceLimits bounds it; every operation and every workspace tool takes limits:. Defaults: readFile reads at most 8 MiB off disk (maxReadBytes) and returns at most 2,000,000 characters (maxReturnedCharacters), cutting at a character boundary and pointing the model at readFileRange; editFile, applyPatch and search won't process a file over 64 MiB (maxFileBytes) — search skips it and says so in SearchResult.skippedTooLarge; one search or readFileRange reads at most 256 MiB in total (maxScanBytes, SearchResult.scanBudgetExhausted); each line given to a regular expression is capped at 8 KiB (maxRegexInputBytes — NSRegularExpression can't be cancelled, so bounding its input is the only thing that bounds its work); writeFile accepts at most 64 MiB (maxWriteBytes).

editFile — the one write operation actually meant for AI-assisted modification of an existing file — is search-and-replace, not a unified-diff/patch format: oldString/newString, and it fails loudly if oldString isn't found or isn't unique in the file (pass replaceAll if you really mean every occurrence). This was a deliberate choice, not an obvious one: a small on-device model reliably producing correct line numbers and context lines for a real diff format is a much harder ask than quoting one exact, minimal, uniquely-identifying snippet — and it's a much simpler, safer thing for Core to validate and apply. writeFile is create-by-default — it fails on an existing file unless you pass overwrite: true, the deliberate opt-in for regenerating a wholly-derived file (a CSV/JSON data export, a report), or append: true to add to the end of one (accumulating a result that arrives in pages). Use editFile for a targeted change to an existing file, same add-vs-update split Calendar/Reminders/Contacts already use.

Path A ready-made Tools ship too, same shape as everywhere else in Core: ListWorkspaceFilesTool, ReadWorkspaceFileTool, WriteWorkspaceFileTool, EditWorkspaceFileTool, DeleteWorkspaceFileTool, SearchWorkspaceTool (plain-text or regex), WorkspaceTreeTool (an indented directory listing), ApplyPatchTool (a unified-diff patch, potentially touching several files in one call), and ReadFileRangeTool (a byte/line window into a large file without reading it whole) — each takes the root URL at init. DeleteWorkspaceFileTool isn't wired into examples/workspace-buddy's default tool list — a coding assistant that can delete files unprompted is a meaningfully bigger risk than one that can only read/create/edit — but it's there if your own app wants it. code-buddy is the example that uses the full set, including SearchWorkspaceTool and ApplyPatchTool for its coding-agent workflow.

One real gotcha, not covered by §8's own example: that section's withFolderAccess<T>(_:) is synchronous, bracketing a single, quick access. If you're handing these tools to a LanguageModelSession — which can invoke several of them over the course of one respond(to:) call — the security-scoped access window has to stay open for that whole async call, not just a synchronous setup step. examples/workspace-buddy shows the async-aware version (withFolderAccessAsync<T>(_:)) this actually requires.

8b. FileBackedTool + the "AIQL" data verbs: a mechanical MCP-dataset → CSV pipeline

Reach for this when a data-source tool (an MCP dataset, an API, a big query) can return more than fits in a small model's context, and you'd rather have the model describe one transform than copy rows through itself. Skip it if your data tools already return small, bounded results.

Examples that use it: aiql is the end-to-end pipeline — a plain-English request over an MCP dataset → FileBackedTool → loadTable/sqlQuery → a CSV in a folder you chose, with a local MLX model.

The problem: a data-source tool (an MCP server tool for a dataset, an API, a big query) can return far more than fits in a small model's context — after the host truncates it the model sees a fraction, and it will paper over the gap rather than stop. Routing a bulk payload through the model is the wrong shape: the model is good at deciding what to extract and how, bad at being a copy buffer. Ask an 8–14B model to copy 80 records into a CSV and it invents the ones it didn't see.

FileBackedTool wraps a dynamic-schema tool (an MCP tool adapter is the motivating case) and adds one root-level argument, saveAs. When the model supplies a path, the wrapped tool's raw result is written to <workspace>/raw/<saveAs> (the default SaveAsPolicy) and only a short receipt — byte/line count and a bounded head preview — returns. The model then works from the file (readFileRange a window → the verbs below), and the payload never enters its context. It's not a Tool that calls another Tool (the model can't invoke that) — it's a decorator the host applies when building the tool array. FileBackedTool.mcp(descriptor:manager:root:) wraps an MCPToolDescriptor in one call; saveAsAppend accumulates paginated pages into one file.

The data verbs — ready-made Tools, each takes the root URL at init, reads one workspace file, does one mechanical transform, writes a CSV back, and returns a one-line receipt (so the row data never reaches the model):

Tool SQL analogue what it does
JSONToCSVTool (jsonToCsv) SELECT cols FROM json_array point rowsAt at the records array (use describeJson first), list {header, path} columns — path is a JSONPath (attributes.EMAIL, regions[0].code) into each record; omit columns to flatten every scalar field
SelectColumnsTool (selectColumns) SELECT a AS x, b project a CSV to chosen columns, reorder, rename
FilterRowsTool (filterRows) WHERE keep rows matching conditions (eq/ne/contains/notContains/startsWith/endsWith/matches/gt/gte/lt/lte/isEmpty/notEmpty), ALL or matchAny
SortRowsTool (sortRows) ORDER BY … LIMIT sort by a column (numeric if it's all-numeric), optional limit — the mechanical answer to "top N", which is exactly where a small model fabricates
DedupeRowsTool (dedupeRows) SELECT DISTINCT drop duplicate rows, whole-row or by on: columns
AggregateRowsTool (aggregateRows) GROUP BY count/sum/avg/min/max per group
ConcatRowsTool (concatRows) UNION ALL stack CSV files, columns matched by name — for paginated pulls or separate exports
DescribeJSONTool (describeJson) — compact structure summary (key paths, types, array lengths) — call before jsonToCsv; read-only
CSVInfoTool (csvInfo) — row count, columns, sample rows — check a stage produced what you expected; read-only

The intended shape is a chain: raw.json → describeJson → jsonToCsv → filterRows → sortRows → out.csv. The building blocks under them — CSVCodec (RFC 4180 encode/decode + a header-keyed Table) and JSONPath (a read-only a.b[0].c resolver over a JSONSerialization value) — are public for writing your own verbs. Pagination: the model calls the data tool once per page with the same saveAs path plus saveAsAppend: true, and jsonToCsv / describeJson read the resulting file of concatenated JSON values ({…}{…}{…}) as one dataset.

loadTable + sqlQuery — real SQL over the pulled data (1.0.0-beta.4). Chaining the verbs asks the model to emit a correct multi-call sequence, and small models drift — most often dropping the filter when it is the third refinement. The answer is not another verb: it is to stop the model orchestrating and have it describe one query. loadTable(jsonPath, tableName) stages a JSON records file into an ephemeral SQLite table at <root>/aiql.sqlite — auto-detects the records array, sniffs INTEGER/REAL/TEXT, splits a nested array into a <table>__<field> child table — and returns the CREATE TABLE. sqlQuery(sql, outputPath) runs one read-only SELECT, opened SQLITE_OPEN_READONLY behind a sqlite3_set_authorizer allowlist (SELECT/READ/FUNCTION only; no DML, DDL, ATTACH), one statement, a 15 s timeout, a 100k-row cap, and writes the CSV. The model never sees a row; only the schema and a result receipt. Two loadTable calls → a JOIN in one SELECT (the cross-dataset case). Ranges, GROUP BY/HAVING, computed columns, sub-queries all come for free. Validated end to end with Qwen3-8B and Qwen3-14B. This is what the aiql example uses; the individual verbs stay as pure-Swift primitives for a pipeline that wants per-stage control or no SQLite link.

examples/aiql is the end-to-end SwiftUI app — a plain-English request over an MCP dataset → this pipeline → a CSV in a folder you chose, with a local MLX model. It builds the session with SessionOptions(effort: .off) so the Qwen3 model skips its <think> pass — the pipeline is mechanical enough that the reasoning trace only adds latency (§6a).

↑ Top

9. Scope and boundaries: what Core doesn't do

Source compatibility is guaranteed from 1.0.0 GA on. Every 1.x release keeps existing code compiling unmodified against a later one. New capability shows up as additive surface (new optional/defaulted parameters, new protocol methods with a default implementation, new enum cases per the @unknown default point below); anything that would force you to edit working code just to keep building waits for 2.0. If you hit "X is inaccessible due to internal protection level" on something that looks like it should be public, file it — that's a bug against the guarantee, not expected churn. This covers source compatibility, not binary — you rebuild against whatever version you pin, there's no supported "drop in a newer xcframework without recompiling" path.

The following are deliberate boundaries, not gaps waiting on a future release:

↑ Top

10. App Sandbox — building for the Mac App Store

Reach for this when you're distributing (or considering distributing) via the Mac App Store, or you just want App Sandbox for its own sake. Skip this whole section if you're only shipping Developer ID + notarization — that path needs none of this.

Examples that use it: plate-today's packaging/ folder has the working scripts for both signing paths — see the closing paragraph of §10d below for which is which.

Core has been tested under App Sandbox — confirmed via a real sandboxed, Developer-ID-signed, notarized build (not just code review). Since 2.1 two apps built on the SDK, LocalLM Lab (with Private Cloud Compute and local MLX models) and JevDK, have also gone through App Store Connect's upload validation into TestFlight; §10e has what that turned up. Here's exactly what's needed and what was actually verified.

Related, for model downloads: the sandbox is one half of the security story for an app that fetches models at runtime. The other half is what it fetches: the trust policy, preflight, hash verification and cache cap under supply-chain hardening, and pinning, updating and cleaning up model versions. A sandboxed app also relocates the Hugging Face cache into its container; the SDK follows it.

10a. Entitlements

Add com.apple.security.app-sandbox alongside whichever connector entitlements from §2b you're already using:

<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>

com.apple.security.network.client is easy to miss and fails silently, not loudly. Under sandbox, any outbound network connection is blocked without it — no crash, no error dialog, just a request that hangs or times out. This bit a real reference app during testing: Weather failed with a generic "connectivity issues" message and the MCP client's server connections never completed, both traced back to this one missing key. If you're using the MCP client, WeatherTool, or anything else that reaches the network, you need this entitlement — it's not optional the way the Calendar/Reminders/Location entitlements are (those are only needed if you're using that specific connector).

10b. What's confirmed working under sandbox, live-tested

Outside the scope of sandbox testing to date: Contacts, and Location's accuracy/reverse-geocoding behavior beyond "it returns something" (a real, pre-existing, non-sandbox-specific bug in reverse geocoding was hit during testing, unrelated to sandboxing itself).

10c. A testing caution: reset TCC before you trust a "no prompt" result

If you add the sandbox entitlement to an app you've already tested unsandboxed under the same bundle identifier, TCC may silently honor grants from that earlier testing — you'll see no permission prompt at all, and it's genuinely ambiguous whether that means "already granted" (fine) or "the prompt is broken under sandbox" (not fine) until you check. Before trusting a "worked with no prompt" result while testing a newly-sandboxed build, reset first:

tccutil reset Calendar <your-bundle-id>
tccutil reset Reminders <your-bundle-id>
tccutil reset AddressBook <your-bundle-id>
tccutil reset All <your-bundle-id>   # Location can't be reset individually, see [§7](#7-connectors-calendar-reminders-contacts-location) (Connectors)

10d. One-time Apple Developer Portal setup for MAS signing

Three artifacts, each depending on the previous one, all created at developer.apple.com/account — a real membership required, and this is genuinely portal/GUI work, not something scriptable. Done once per app; the certificate can be reused across apps, the App ID and profile are per-app.

1. An Apple Distribution certificate (covers both app and installer signing in the modern single-certificate flow — some older accounts may still use the separate "3rd Party Mac Developer Application"/"3rd Party Mac Developer Installer" pair instead, which works the same way).

2. An App ID, explicit (not wildcard), matching your app's real bundle identifier.

Starting from plate-today or components-demo? Change CFBundleIdentifier in Info.plist first. App ID registration is global across Apple's entire Developer Portal, not scoped per team. plate-today's bundle ID (lab.locallm.sdk.reference.platetoday) is already registered under this project's own team (it has a real MAS provisioning profile — see the walkthrough below), so no other developer's account can register it. components-demo has no MAS path today and its bundle ID (lab.locallm.sdk.reference.componentsdemo) isn't currently registered as an App ID at all — but don't build a real product under it regardless: it's this project's own reverse-DNS namespace, and if a MAS path is ever added for components-demo later, this project would register it too. Building and running either example locally works fine unmodified — CFBundleIdentifier doesn't need to be globally unique for Developer ID/ad-hoc signing — but MAS submission of your own app needs your own bundle ID from the start.

3. A Mac App Store distribution provisioning profile, tying the App ID to the certificate.

examples/plate-today/packaging/build-and-sign-mas.sh is a complete, working example of the Apple-Distribution-signed, sandboxed .pkg build this setup enables — Developer ID signing + notarization is the other supported path (build-and-sign.sh), for distributing outside the Mac App Store.

10e. Shipping the model layer through App Store Connect

What uploading LocalLM Lab and JevDK to App Store Connect turned up. None of it needed an SDK API change.

Embedding LocalLMLabSDKInference (local MLX models). Use SDK 2.1 or later. In 2.0 the framework's bundle ID was mlx-swift_Cmlx, and App Store Connect rejects an app containing a bundle ID with an underscore (error 90277: not a valid bundle identifier). Since 2.1 it is lab.locallm.sdk.inference, and the xcframework embeds as is: no renaming, and no copying mlx-swift_Cmlx.bundle into your app's Resources. If you did that for 2.0, remove it when you upgrade: the copy and the framework's own copy share the bundle ID mlx-swift.Cmlx.resources, and a duplicate bundle ID can fail validation too (90685). MLX loads its Metal library from the framework's own Versions/A/Resources/default.metallib, so don't strip the framework's Resources. Before an upload, check that every bundle ID in the app is only letters, digits, hyphens and dots:

find "MyApp.app" -name Info.plist -exec /usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' {} \; 2>/dev/null \
  | grep -vE '^[A-Za-z0-9.-]+$' || echo "all bundle IDs valid"

Private Cloud Compute. com.apple.developer.private-cloud-compute is a managed capability:

import Security

let pccEntitled: Bool = {
    guard let task = SecTaskCreateFromSelf(nil),
          let value = SecTaskCopyValueForEntitlement(
              task, "com.apple.developer.private-cloud-compute" as CFString, nil) else { return false }
    return (value as? Bool) ?? true
}()
if pccEntitled { providers.append(PCCModelProvider()) }

LocalLM Lab's own Developer ID build (the DMG) doesn't carry the PCC entitlement, so it has no PCC; its Mac App Store build does. Whether a Developer ID provisioning profile can grant it hasn't been tested.

↑ Top

11. Components: prebuilt SwiftUI (MCP servers + the model layer)

Reach for this when you want a working settings surface — for MCP servers (add / remove / reconnect, all three auth types from §3, per-tool and per-resource enable/disable, resource + prompt browsing) or the model layer (pick a model, download an open-weight one from Hugging Face with a progress bar, see on-disk size, configure an online provider + key + web search) — without building that UI yourself. It's SwiftUI, entirely optional, and layered strictly on Core's public API (nothing here you couldn't write). Skip it if your app has no user-facing configuration, or your design is too bespoke to reuse these views — go straight to MCPServerManager (§6) or lab.models (§6a).

Examples that use it: components-demo — essentially the whole app is the MCP views; model-switch — the online-provider panel (AIModelsSettingsView); components-updates-demo — the model onboarding, update and cleanup views, driven from simulated data (and mlx-control-room runs the same flows against a real MLXModelProvider).

See annotated-examples.md for both apps' full source with every Components/Core touchpoint marked.

Everything above is Core — a plain Swift engine with no UI dependency. Components is a separate, optional package built on top of Core's public API. It ships one binary-artifact dependency on Core.xcframework and no source access to Core's internals.

MCP-server views:

Model-layer views (all bind directly to lab.models, an @Observable ModelRegistry — no polling):

ModelPickerView (local models + MLX download) and AIModelsSettingsView (online providers) are two separate surfaces by design — compose a full "AI Models" panel by presenting whichever your app needs, or stacking both.

None of these views hold persistence of their own — the MCP views go through manager.core.restore(from:), the model views through lab.snapshot() / lab.restore(from:) and your own Keychain, at launch, with whatever you've saved.

↑ Top

12. Full function/type reference

Everything public in LocalLMLabSDKCore, LocalLMLabSDKClaude, LocalLMLabSDKInference, LocalLMLabSDKRemote, and LocalLMLabSDKComponents, grouped by area. This is the flat list; sections 1–11 above are the narrative version with context and gotchas.

You don't need to read this section in order. Jump straight to the area you need and check the signature; the numbered sections above are the intended learning path.

The machine-checked source of truth is api-surface.md (regenerated from the compiled .swiftinterfaces). If this section and that one disagree, that one is right — file it.

Which example shows each area (source you can read + run):

API area Example Path
The model layer — LocalLMLab, routing, MLXModelProvider, sessions, residency, ContextBudget code-buddy (full) · repo-qa-local (minimal) —
Workspace tools (WorkspaceAccess / SearchWorkspaceTool / ApplyPatchTool / …) workspace-buddy (on-device) · workspace-buddy-local (MLX, sandboxed) · code-buddy A
Connectors + ready-made connector Tools plate-today-tools A
Connectors via hand-written Tool adapters plate-today B
MCP client + OAuth (Todoist) + Keychain plate-today / plate-today-tools —
MCPTool built from a live server schema (no hand-written Arguments) repo-qa · repo-qa-local A
Components — MCPServerPickerView / MCPServerManagerObservable / resources / prompts components-demo —
Components — model layer (ModelPickerView / AIModelsSettingsView / ProviderSettingsSection) model-switch (AIModelsSettingsView) —

The model layer (LocalLMLab, routing, providers, sessions)

// --- front door ---------------------------------------------------------------
@MainActor final class LocalLMLab {
    struct Configuration {
        var providers: [any ModelProvider]; var state: LocalLMLabState?; var mcp: MCPSettings
        init(providers: [any ModelProvider] = [], state: LocalLMLabState? = nil, mcp: MCPSettings = MCPSettings())
    }
    let models: ModelRegistry
    let mcp: MCPServerManager           // the one manager, made from Configuration.mcp
    let connectors: ConnectorsFacade    // .calendar/.reminders/.contacts/.location permission lifecycle
    let workspace: WorkspaceFacade
    init(configuration: Configuration = .init())
    func snapshot() -> LocalLMLabState                 // route map + residency + installed records; NOT weights
    func restore(from state: LocalLMLabState)
    // from LocalLMLab+makeSession:
    func makeSession(route: RouteName, tools: [any Tool] = [], instructions: String? = nil,
                     restoring transcript: Transcript? = nil,      // continue a saved conversation (instructions nil)
                     includeMCPTools: Bool = true,                  // and keep following lab.mcp between turns
                     mcpAppHints: Bool = false,                     // [§3f](#3f-mcp-apps-showing-a-servers-interactive-views)
                     options: SessionOptions = .init(),
                     authorizer: (any ToolCallAuthorizer)? = nil) throws -> LocalLMLabSession   // authorizer: [§7c](#7c-tool-authorization-two-levers--which-tools-and-whether-they-ask-first)
}

struct MCPSettings {                    // how the lab sets up lab.mcp — MCPServerManager's own defaults
    var handlers: MCPClientHandlers      // incl. extensions, e.g. .advertisingMCPApps() (LocalLMLabSDKMCPAppsHost)
    var responseLimits: MCPResponseLimits
    var versionNegotiation: MCPVersionNegotiation   // .auto
    var liveUpdates: Bool                            // true
    init(handlers: MCPClientHandlers = MCPClientHandlers(), responseLimits: MCPResponseLimits = .default,
         versionNegotiation: MCPVersionNegotiation = .auto, liveUpdates: Bool = true)
}

struct LocalLMLabState: Codable, Sendable, Equatable {
    static let currentVersion = 1
    var version: Int
    var routes: [RouteName: ModelID]
    var residency: ModelResidency
    // + installed-model records
}

// --- registry ----------------------------------------------------------------
@MainActor final class ModelRegistry {
    var providers: [any ModelProvider] { get }
    var routes: [RouteName: ModelID] { get }
    var residency: ModelResidency                       // set .keepWarm(routes) to prewarm + hold
    let events: AsyncStream<ResidencyEvent>
    func applyResidency() async
    func route(_ route: RouteName, to id: ModelID)
    func modelID(for route: RouteName) -> ModelID?
    func register(_ provider: any ModelProvider) throws  // throws .schemeAlreadyRegistered
    func provider(for id: ModelID) -> (any ModelProvider)?
    func availability(for id: ModelID) -> ModelAvailability
    static let macOS27OnlySchemes: Set<String>          // ["pcc", "claude", "mlx"]
    var schemesRequiringNewerOS: [String] { get }       // on macOS 26: the above, minus any registered
    var downloadableProviders: [any DownloadableModelProvider] { get }
    var installedModels: [InstalledModel] { get }
    var knownModels: [ModelID] { get }                  // union of providers' advertisedModels
    var downloads: [ModelID: DownloadProgress] { get }  // in-flight — bytesReceived/totalBytes/fraction
    func startDownload(_ repoID: String) async throws -> InstalledModel
    func cancelDownload(_ repoID: String)               // no-op if repoID has no in-flight download
}
struct DownloadProgress: Sendable, Equatable {
    var bytesReceived: Int64
    var totalBytes: Int64
    var fraction: Double
}
enum ModelRegistryError: Error, Equatable, CustomStringConvertible { case schemeAlreadyRegistered(String); case noDownloadableProvider }

// --- providers -------------------------------------------------------------
protocol ModelProvider: Sendable {
    static var scheme: String { get }
    func owns(_ id: ModelID) -> Bool                    // default: id.scheme == Self.scheme
    func makeSession(for id: ModelID, tools: [any Tool], instructions: String?,
                     transcript: Transcript?) throws -> LanguageModelSession
    func availability(for id: ModelID) -> ModelAvailability
    func prewarm(_ id: ModelID) async                   // no-op default
    var advertisedModels: [ModelID] { get }             // [] default
}
protocol DownloadableModelProvider: ModelProvider {
    var installed: [InstalledModel] { get }
    func download(_ repoID: String) -> AsyncThrowingStream<DownloadEvent, any Error>   // 0+ .progress, then exactly one .completed, or throws
    func cancelDownload(_ repoID: String)   // aborts the real transfer, not just local consumption of the stream
    func validate(_ repoID: String) async throws -> PreflightResult                    // no weights pulled
    func capabilityProbe(_ id: ModelID) async -> ModelCapabilityReport                 // authoritative for that model's ModelCapabilities
    func remove(_ id: ModelID) throws
    var storageUsed: Int64 { get }
    var residencyEventStream: AsyncStream<ResidencyEvent>? { get }                      // nil default
}
struct SystemModelProvider: ModelProvider { init() }                    // Core
@available(macOS 27, *) struct PCCModelProvider: ModelProvider {           // Core
    init()
    func probe(timeout: Duration = .seconds(8)) async -> ModelAvailability // authoritative PCC liveness check (beta.3+)
}

// --- ClaudeModelProvider — LocalLMLabSDKClaude (separate xcframework, macOS 27) ---
struct ClaudeModelProvider: ModelProvider {
    enum Auth: Sendable { case apiKey(String); case appAttest(clientID: String) }
    init(auth: ClaudeModelProvider.Auth, extraModels: [String: ClaudeModelSpec] = [:])
}
struct ClaudeModelSpec: Sendable, Hashable { /* one Claude model — id, display name, context window */ }

// --- RemoteModelProvider — LocalLMLabSDKRemote (separate xcframework; manifest floor macOS 26,
// like Core — linking it never forces a 27 deployment target; the provider itself reports
// .requiresOS("macOS 27") at runtime on 26, same as pcc) — [§6b](#6b-online-providers--gpt-claude-online-openrouter-locallmlabsdkremote) ---
@available(macOS 27, *)
struct RemoteModelProvider: ModelProvider {
    // Always validates — the only initializer without "unchecked" in its
    // name, so there's no shorter unvalidated spelling to reach for by mistake.
    init(_ config: RemoteProviderConfig) throws                 // runs config.validated()
    init(unchecked config: RemoteProviderConfig)                // explicit bypass — trusted configs only
    func probe(for id: ModelID, timeout: Duration = .seconds(8)) async -> ModelAvailability   // zero-token key/model/reachability check
    func turnArtifacts(in transcript: Transcript) -> TurnArtifacts   // server-tool activity + citations for the last turn
}
struct RemoteProviderConfig: Sendable, Equatable {
    var scheme: String                          // the app names it — not privileged like "claude"
    var displayName: String
    var dialect: Dialect
    var baseURL: URL
    var auth: Auth
    var models: [RemoteModel]                   // SDK ships no defaults — see [§6b](#6b-online-providers--gpt-claude-online-openrouter-locallmlabsdkremote)
    var capabilities: Set<Capability>            // .webSearch, …
    var defaultOptions: SessionOptions
    var allowArbitraryModelIDs: Bool = false     // true → any "scheme:<string>" routes (OpenRouter)
    var responseLimits: RemoteResponseLimits = .default   // see below
    init(scheme: String, displayName: String, dialect: Dialect, baseURL: URL, auth: Auth,
         models: [RemoteModel] = [], capabilities: Set<Capability> = [],
         defaultOptions: SessionOptions = .init(), allowArbitraryModelIDs: Bool = false,
         responseLimits: RemoteResponseLimits = .default)
}
struct RemoteModel: Sendable, Hashable, Codable, Identifiable {
    var id: String; var displayName: String; var contextWindow: Int?
    init(id: String, displayName: String? = nil, contextWindow: Int? = nil)
}
extension RemoteProviderConfig {
    enum Dialect: String, Sendable, Hashable, Codable { case openAIChat, openAIResponses, anthropicMessages }
    enum Auth: Sendable, Hashable { case apiKey(String); case header(name: String, value: String); case none }
    enum Capability: String, Sendable, Hashable, Codable { case webSearch, structuredOutput, imageInput }

    // Presets — conveniences, zero privilege. Model lists are empty by default (the SDK ships no
    // model ids the host didn't ask for — a hardcoded default drifts and 404s; [§6b](#6b-online-providers--gpt-claude-online-openrouter-locallmlabsdkremote)).
    static func openAI(apiKey: String, models: [RemoteModel] = []) -> Self
    static func anthropic(apiKey: String, models: [RemoteModel] = []) -> Self
    static func openRouter(apiKey: String, models: [RemoteModel] = []) -> Self   // allowArbitraryModelIDs = true
    static func openAICompatible(scheme: String, displayName: String, baseURL: URL, apiKey: String?) -> Self

    // Run this on any config whose baseURL/auth came from outside your own
    // code (a pasted URL, an imported profile) — throws ValidationIssue rather than silently
    // sending a key to an untrusted or non-HTTPS host. Syntax check, not a trust check.
    func validated() throws -> RemoteProviderConfig
    enum ValidationIssue: Error, LocalizedError, Equatable {
        case insecureBaseURL(host: String)      // non-HTTPS to a non-local host
        case unsupportedURLScheme(String)       // not http(s)
        case missingHost
        case malformedHeaderName(String)
        case controlCharactersInCredential       // CR/LF in a credential — header injection
    }
}
enum RemoteError: Error, LocalizedError {   // wrapped in LocalLMLabError.generation
    case http(status: Int, message: String)
    case transport(Error)
    case dialectNotImplemented(String)
    case responseTooLarge(String)           // a RemoteResponseLimits bound was exceeded
}

// A custom OpenAI-compatible endpoint is exactly as untrusted as an MCP
// server — this is the Remote-side equivalent of MCPResponseLimits. Applied per-provider via
// RemoteProviderConfig.responseLimits; defaults suit a well-behaved provider.
struct RemoteResponseLimits: Sendable, Hashable {
    var maxResponseBytes: Int             // one non-streaming response body (OpenAI Responses)
    var maxStreamedBytes: Int             // total bytes across an entire SSE stream
    var maxSSELineBytes: Int              // one SSE line / `data:` frame
    var maxEmittedTextBytes: Int          // total assistant text emitted for one turn
    var maxToolCallArgumentBytes: Int     // one tool call's accumulated streamed arguments
    var maxErrorBodyBytes: Int            // a non-2xx error body, before deriving a message
    static let `default`: Self
}

// --- MLXModelProvider — LocalLMLabSDKInference (separate xcframework, macOS 27) ---
@available(macOS 27, *)
struct MLXModelProvider: DownloadableModelProvider {
    static var scheme: String { "mlx" }
    init(cacheDirectory: URL? = nil, residentModelLimit: Int = 1, preflightLimits: MLXPreflightLimits = .init(),
         pinnedRevisions: [String: String] = [:],   // repo id -> HF revision/commit, for reproducible downloads
         supplyChainPolicy: MLXSupplyChainPolicy = .default,   // trust policy + hash verification + cache cap
         pinStore: (any MLXPinStore)? = nil,                   // captured pins (trust on first use); MLXFilePinStore()
         managedPinStore: (any MLXManagedPinStore)? = nil)     // developer-named updates to shipped pins
    var residencyEventStream: AsyncStream<ResidencyEvent>?
    var advertisedModels: [ModelID] { get }
    var installed: [InstalledModel] { get }
    var storageUsed: Int64 { get }
    func makeSession(for id: ModelID, tools: [any Tool], instructions: String?,
                     transcript: Transcript?, options: SessionOptions) throws -> LanguageModelSession
    func availability(for id: ModelID) -> ModelAvailability
    func prewarm(_ id: ModelID) async
    func download(_ repoID: String) -> AsyncThrowingStream<DownloadEvent, any Error>
    func cancelDownload(_ repoID: String)
    func validate(_ repoID: String) async throws -> PreflightResult
    func capabilityProbe(_ id: ModelID) async -> ModelCapabilityReport
    func remove(_ id: ModelID) throws
    func unloadResident(_ id: ModelID, reason: String = "idleTimeout") async
    func unloadAllResident(reason: String = "idleTimeout") async
    // model pairing — runtime-changeable, re-applied on
    // every makeSession call for the paired base repo id
    func pairDraftModel(_ spec: SpeculativeDecodingSpec?, with baseRepoID: String)   // speed helper; nil clears
    func pairAdapter(_ spec: AdapterSpec?, with baseRepoID: String)                  // specialization patch; nil clears
    // pins, updates, cleanup
    func effectivePin(for repoID: String) -> MLXPin?          // pin in force + its source
    func buildTimePin(for repoID: String) -> String?          // the pin this build shipped, if any
    func checkPinUpdate(_ repoID: String, to revision: String? = nil) async throws -> MLXPinUpdateCheck   // downloads nothing
    func updatePin(_ repoID: String, to revision: String? = nil,
                   beforeSwitch: (@Sendable () async throws -> Void)? = nil) -> AsyncThrowingStream<DownloadEvent, any Error>
    func updatePins(_ updates: [String: String],
                    beforeSwitch: (@Sendable () async throws -> Void)? = nil) -> AsyncThrowingStream<DownloadEvent, any Error>   // all-or-nothing
    func snapshots(for repoID: String) -> [MLXCachedSnapshot]                     // cached versions, exclusive vs shared bytes
    func removeSnapshot(_ repoID: String, revision: String) throws -> Int64       // refuses the current version; returns bytes freed
}
struct MLXPreflightLimits: Sendable, Equatable {
    var maxWeightFractionOfRAM: Double     // default 0.7 — also the combined base+draft ceiling for a speed-helper pairing
    init(maxWeightFractionOfRAM: Double = 0.7)
}

// --- model pairing (LocalLMLabSDKInference) ---------
// "Speed helper": pairs a base model with a smaller, faster draft model that proposes several
// tokens ahead per round for the base model to verify in bulk — no change in output quality
// when it works. Measured on mlx-control-room: faster with a short draft (the default, 2), slower
// with a long one (5) — see the pairing notes above, and verify on your own setup.
struct SpeculativeDecodingSpec: Sendable, Equatable {
    var draftRepoID: String    // must share the base model's tokenizer/family, just much smaller
    var numDraftTokens: Int    // tokens proposed per round; default 2
    init(draftRepoID: String, numDraftTokens: Int = 2)
}
// "Specialization patch": pairs a base model with a LoRA/DoRA adapter that nudges its behavior
// (tone, vocabulary, a skill) without a whole second model. Applying an adapter mutates the
// model in place, so a paired session gets its own loaded copy of the base weights — separate
// from, and not evicted by, a plain unadapted session to the same repo.
enum AdapterSource: Sendable, Equatable {
    case huggingFace(repoID: String)   // repo with adapter_config.json + adapters.safetensors
    case directory(URL)                // same two files already on disk — no download
}
struct AdapterSpec: Sendable, Equatable {
    var source: AdapterSource
    init(source: AdapterSource)
}

// --- supply-chain hardening (LocalLMLabSDKInference) ----------------------
struct MLXSupplyChainPolicy: Sendable {
    var verification: MLXWeightVerification
    var trustPolicy: (any MLXModelTrustPolicy)?
    var cacheLimits: MLXCacheLimits
    init(verification: MLXWeightVerification = .default, trustPolicy: (any MLXModelTrustPolicy)? = nil,
         cacheLimits: MLXCacheLimits = .default)
    static let `default`: MLXSupplyChainPolicy
}
protocol MLXModelTrustPolicy: Sendable {
    func evaluate(repoID: String) async -> MLXModelTrustDecision
}
enum MLXModelTrustDecision: Sendable, Equatable {
    case allow
    case deny(reason: String)
}
enum MLXWeightVerification: Sendable, Equatable {
    case enabled     // default — verify every file's hash before caching it
    case disabled    // for a non-standard mirror whose ETags don't match a recognized shape
    static let `default`: MLXWeightVerification
}
struct MLXWeightVerificationError: Error, LocalizedError, Sendable {
    enum Reason: Sendable, Equatable {
        case hashMismatch(expected: String, computed: String)
        case unrecognizedETagShape(String)
    }
    var filename: String
    var reason: Reason
}
struct MLXCacheLimits: Sendable, Equatable {
    var maxTotalCacheBytes: Int64?   // nil (default) = unlimited; checks the WHOLE cache dir, not just this app's downloads
    init(maxTotalCacheBytes: Int64? = nil)
    static let `default`: MLXCacheLimits
}

// --- model identity + capability ------------------------------------------
struct ModelID: Hashable, Sendable, Codable, CustomStringConvertible, LosslessStringConvertible {
    let scheme: String        // token before the first ':' — [a-z][a-z0-9]*
    let rest: String          // after the first ':'; empty for bare ids
    var rawValue: String      // "scheme" or "scheme:rest"
    init?(_ raw: String)
    init?(scheme: String, rest: String = "")
    static let system: ModelID    // "system"
    static let pcc: ModelID       // "pcc"
}
enum ModelAvailability: Sendable, Equatable {
    case available
    case notDownloaded
    case needsCredential
    case unavailable(kind: UnavailableKind, detail: String)
    enum UnavailableKind: Sendable, Equatable {
        case ineligibleHardware, notEnabled, modelNotReady, unsupportedModel, providerError, noProvider
        case requiresOS(String)   // e.g. .requiresOS("macOS 27") — returned on macOS 26 for pcc/claude/mlx
    }
    var isAvailable: Bool { get }
}
struct ModelCapabilities: OptionSet, Sendable, Hashable, Codable {
    static let toolCalling: ModelCapabilities        // 1 << 0
    static let guidedGeneration: ModelCapabilities   // 1 << 1
    static let reasoningToggle: ModelCapabilities    // 1 << 2 — effort: .off actually suppresses this model's thinking
}
struct ModelCapabilityReport: Sendable, Equatable {
    var id: ModelID
    var capabilities: ModelCapabilities
    var notes: [String]
    var blocked: String?          // non-nil = the model downloaded but can't run at all (load error, image-required VLM, empty output)
    var isUsable: Bool { get }
}
struct InstalledModel: Sendable, Hashable, Codable, Identifiable {
    var id: ModelID
    var repoID: String
    var resolvedRevision: String   // the immutable commit this download actually resolved to — persist it
                                    // yourself (not under the HF cache dir) to redownload the same content
                                    // later, e.g. after remove(_:) or the OS reclaiming space
    var capabilities: ModelCapabilities       // empty until capabilityProbe
    var sizeBytes: Int64?
    var contextTokens: Int?
    init(id: ModelID, repoID: String, resolvedRevision: String, capabilities: ModelCapabilities = [],
         sizeBytes: Int64? = nil, contextTokens: Int? = nil)
}
struct PreflightResult: Sendable, Equatable {
    enum Stage: String, Sendable, Codable { /* trustPolicy / repoReachable / mlxFormat / architectureSupported / sizeVsMemory / diskSpace / cacheQuota */ }
    var failedStage: Stage?       // nil = passed
    var detail: String?
    var weightBytes: Int64?
    var ramBytes: Int64?          // this Mac's physical memory, for the size check
    var passed: Bool { get }
    static let ok: PreflightResult
}

// --- routing + residency + events ----------------------------------------
struct RouteName: Hashable, Sendable, Codable, CustomStringConvertible, ExpressibleByStringLiteral {
    let rawValue: String
    init(_ rawValue: String); init(stringLiteral: String)
    static let heavy: RouteName   // "heavy"
    static let light: RouteName   // "light"
    static let local: RouteName   // "local"
}
enum ModelResidency: Sendable, Equatable, Codable { case lastUsedOnly; case keepWarm([RouteName]) }
enum ResidencyEvent: Sendable {
    case warmed(ModelID)
    case evicted(ModelID, reason: String)          // "capacity", "memoryPressure", …
    case loadProgress(ModelID, fraction: Double)
}

// --- session -------------------------------------------------------------
struct LocalLMLabSession: Sendable {
    let route: RouteName
    let modelID: ModelID
    var retryOnContextOverflow: RetryPolicy         // .disabled by default; set before respond()
    var turnContext: (@Sendable () -> String?)?    // sent ahead of every prompt (e.g. the date); not shown in hostTranscript
    var hostTranscript: HostTranscript { get }      // the conversation as a UI shows it (below)
    var languageModelSession: LanguageModelSession { get }   // Apple's session — the escape hatch; a turn run on it skips the SDK's turn handling ([Running a turn](#running-a-turn-and-when-to-use-languagemodelsession))
    var events: AsyncStream<SessionEvent> { get }
    var citations: [Citation] { get }               // web-search sources accumulated this session ([§6b](#6b-online-providers--gpt-claude-online-openrouter-locallmlabsdkremote))
    func reconcileServerToolArtifacts()             // re-derive citations/events from the transcript after a manual edit
    var contextBudget: ContextBudget { get }
    func cancel()
    // Running a turn: hostTranscript, turnContext, widget context, MCP tool refresh, retryOnContextOverflow
    @discardableResult
    func respond(to prompt: String, options: SessionOptions = .init(),      // per-call options: on-device model only
                 fromAppInstance instance: String? = nil) async throws -> String   // instance: a message an MCP App view sent
    func streamResponse(to prompt: String, options: SessionOptions = .init(),
                        fromAppInstance instance: String? = nil) -> AsyncThrowingStream<String, any Error>   // the reply so far
    // Outside the model's loop (MCP App views, your app): same authorizer, recorded in hostTranscript
    @MainActor func callMCPTool(server: MCPServerID, tool: String, arguments: [String: MCPValue],
                                initiator: ToolCallInitiator) async -> Result<MCPToolResult, MCPServerError>
    @MainActor func updateModelContext(_ text: String, fromAppInstance instance: String)   // sent with the next turn
}

@MainActor @Observable final class HostTranscript {
    var entries: [Entry] { get }                    // in model order; append-only
    var replyInProgress: ReplyInProgress? { get }   // while streamResponse runs
    var toolCalls: [ToolCallRecord] { get }
    func toolCall(id: String) -> ToolCallRecord?
    func appOnlyCallCount(for instance: String) -> Int   // an MCP App view's calls to app-only tools (not recorded)
    var resultMemoryBudget: Int                     // bytes of full MCP results kept in memory (8 MB); the rest spill to disk
    var spillDirectory: URL?                        // nil: a temp directory the SDK deletes
    func archive() throws -> Data                   // what the user saw, incl. full results
    func restore(from data: Data) throws            // into an empty transcript; ArchiveError otherwise
    struct Entry: Identifiable, Sendable {
        let id: String; var content: Content; let turn: Int
        var appInstance: String?                     // set when an MCP App view sent it
        enum Content: Sendable {                     // non-frozen: switch with @unknown default
            case user(String), assistant(String), reasoning(String)   // an inline <think> block becomes .reasoning
            case toolCall(ToolCallRecord)
            case appContext(instance: String, text: String)
        }
    }
    struct ReplyInProgress: Sendable, Equatable { var reasoning: String?; var text: String; var isReasoning: Bool }
    enum ArchiveError: Error, Equatable { case notEmpty, unsupportedFormat(String) }
}
struct ToolCallRecord: Identifiable, Sendable {
    let id: String                       // same as the SessionEvent id
    var modelCallID: String?             // the model's own call id, when the model made the call
    let tool: ToolReference              // .mcp(server:tool:) | .host(name:) | .provider(ServerToolActivity)
    let initiator: ToolCallInitiator     // .model | .app(instance:) | .host
    let arguments: GeneratedContent?; let argumentsDescription: String
    var state: State                     // .awaitingApproval | .running | .finished | .failed(reason:) | .denied(reason:)
    var output: String?                  // what the model saw
    var mcpResult: MCPToolResult? { get }   // the full result (read back from disk if spilled)
    let app: MCPAppLink?                 // the tool's MCP App view, if any
    let toolDescriptor: MCPToolDescriptor?
    let startedAt: Date; var finishedAt: Date?
}
enum SessionEvent: Sendable {
    case modelLoadProgress(fraction: Double)
    case routeSwitched(ModelID)
    case contextCompacted(removedEntries: Int)
    case toolCallStarted(id: String, name: String, arguments: String)
    case toolCallFinished(id: String, name: String, failed: Bool, resultSummary: String?)
    case serverToolCall(ServerToolActivity)         // provider-run web search/fetch — [§6b](#6b-online-providers--gpt-claude-online-openrouter-locallmlabsdkremote)
}

// --- per-query options + the web-search side channel ([§6b](#6b-online-providers--gpt-claude-online-openrouter-locallmlabsdkremote)) ---------------
struct SessionOptions: Sendable, Equatable {
    var webSearch: Bool = false
    var webSearchMaxUses: Int? = nil
    var webSearchAllowedDomains: [String] = []
    var webSearchBlockedDomains: [String] = []
    var effort: Effort? = nil            // reasoning level; nil = provider default ([§6a](#6a-the-model-layer-local-models-routing-sessions) "options: SessionOptions")
    var temperature: Double? = nil       // MLX, Apple on-device (0 = greedy), remote; not PCC
    var topP: Double? = nil              // MLX, Apple on-device (exclusive with topK there), remote
    var maxOutputTokens: Int? = nil
    var userLocation: UserLocation? = nil   // for web-search localisation
    var seed: UInt64? = nil              // MLX + Apple on-device (needs topK/topP there) — sampler seed; best-effort on Apple
    var topK: Int? = nil                 // MLX + Apple on-device (exclusive with topP there) — top-k sampling
    var minP: Double? = nil              // MLX only — min-p sampling threshold
    var repetitionPenalty: Double? = nil // MLX only — nil = disabled; the loop/repeat fix
    var repetitionContextSize: Int? = nil  // MLX only — how far back repetitionPenalty looks
    var prefillStepSize: Int? = nil      // MLX only — prompt-prefill chunk size; affects time-to-first-token, not output text
    init(webSearch: Bool = false, webSearchMaxUses: Int? = nil, webSearchAllowedDomains: [String] = [],
         webSearchBlockedDomains: [String] = [], effort: Effort? = nil, temperature: Double? = nil,
         topP: Double? = nil, maxOutputTokens: Int? = nil, userLocation: UserLocation? = nil,
         seed: UInt64? = nil, topK: Int? = nil, minP: Double? = nil, repetitionPenalty: Double? = nil,
         repetitionContextSize: Int? = nil, prefillStepSize: Int? = nil)
    var serverTools: Set<ServerTool> { get }        // derived from webSearch/webSearchMaxUses/etc.
    func merged(onto base: SessionOptions) -> SessionOptions   // per-call wins; nil falls through to base
    func appleGenerationOptions() throws -> GenerationOptions  // the Apple on-device mapping; throws SessionOptionsError
}
/// Thrown by `makeSession` / `respond` / `appleGenerationOptions()` for an option combination Apple's
/// on-device model can't honour, instead of dropping a knob silently.
enum SessionOptionsError: Error, LocalizedError, Equatable {
    case topKAndTopPBothSet                         // Apple's sampler takes top-K or top-P, not both
    case seedRequiresTopKOrTopP                     // a seed only exists inside a random sampling mode
    case invalidValue(field: String, detail: String)
    case perCallOptionsUnsupported(model: String)   // respond(to:options:) on a non-`system` session
}
/// Reasoning/thinking effort, ordered off → max. `.off` is a real guarantee only on the local MLX
/// tier — it suppresses the `<think>` block for toggle-capable chat templates (Qwen3 and its
/// siblings). `.off` never throws: a model that always reasons (DeepSeek-R1 and its distills)
/// just keeps reasoning instead of failing the turn — it's a best-effort preference, not a
/// requirement. Check `ModelCapabilities.reasoningToggle` (from `capabilityProbe(_:)`) ahead of
/// time if you need to know whether `.off` will actually suppress a given model's output. Remote
/// providers (OpenAI/Claude online/OpenRouter) and Apple on-device/PCC have no true "off" switch
/// and treat `.off` as `nil` (provider default) — to minimize reasoning on those, pass `.low`.
enum Effort: String, Sendable, Hashable, CaseIterable, Codable { case off, low, medium, high, xhigh, max }
struct UserLocation: Sendable, Equatable, Codable {
    var city: String?; var region: String?; var country: String?; var timezone: String?
    init(city: String? = nil, region: String? = nil, country: String? = nil, timezone: String? = nil)
}
enum ServerTool: Sendable, Hashable {     // provider-executed, distinct from a client Tool
    case webSearch(maxUses: Int? = nil, allowedDomains: [String] = [], blockedDomains: [String] = [])
    case webFetch
}
struct ServerToolActivity: Sendable, Equatable, Identifiable {
    let id: String
    var kind: Kind
    enum Kind: Sendable, Equatable {
        case webSearch(queries: [String], hits: [WebHit])
        case webFetch(url: String)
    }
}
struct WebHit: Sendable, Equatable, Codable { var url: String; var title: String; var snippet: String? }
struct Citation: Sendable, Equatable, Codable, Identifiable {
    var id: String; var url: String; var title: String; var citedText: String?
}
/// What `RemoteModelProvider.turnArtifacts(in:)` (and Claude's provider) extract from a turn's
/// transcript metadata — how `session.citations` and `.serverToolCall` events get populated.
/// Most callers read `session.citations` / the event stream instead of this directly.
struct TurnArtifacts: Sendable, Equatable {
    var serverToolActivities: [ServerToolActivity] = []
    var citations: [Citation] = []
    static let none: TurnArtifacts
}
struct RetryPolicy: Sendable {
    var maxRetries: Int                                          // 0 disables
    var compact: (@Sendable (Transcript) -> Transcript)?
    init(maxRetries: Int = 0, compact: (@Sendable (Transcript) -> Transcript)? = nil)
    static let disabled: RetryPolicy
}
struct ContextBudget: Sendable, Equatable {
    let windowTokens: Int?          // nil for arbitrary downloaded weights
    let lastInputTokens: Int
    let lastOutputTokens: Int
    var fractionUsed: Double?
    static func windowHint(for id: ModelID) -> Int?
}
enum LocalLMLabError: Error, LocalizedError {   // the one public error type the model layer throws
    case modelUnavailable(reason: String)
    case download(stage: String, underlying: (any Error)?)
    case validation(stage: PreflightResult.Stage, detail: String)
    case generation(underlying: any Error)      // wrap; use GenerationErrorDescription.describe for the message
    case context(detail: String)
    case mcp(detail: String)
    case connector(detail: String)
}
enum LocalLMLabSDKVersion { static let current: String }   // "1.0.0-beta.N", "1.0.0" at GA

MLXModelProvider / MLXPreflightLimits are in LocalLMLabSDKInference, ClaudeModelProvider / ClaudeModelSpec in LocalLMLabSDKClaude, and RemoteModelProvider / RemoteProviderConfig / RemoteError in LocalLMLabSDKRemote (each a separate xcframework on the same release — §1a, §6b); ModelPickerView / ClaudeAuthField / AIModelsSettingsView are in LocalLMLabSDKComponents (§11).

Decision models (lab.decide, OpenJev, hosted Jev)

Walkthrough: §6c. Core unless marked; OpenJev* is LocalLMLabSDKInference (macOS 27), Jev* is LocalLMLabSDKRemote.

// --- asking -----------------------------------------------------------------
extension LocalLMLab {
    func decide(route: RouteName, state: DecisionState, questions: [DecisionQuestion]) async throws -> Decision
    func evaluate(route: RouteName, questions: [DecisionQuestion], answerSet: DecisionAnswerSet,
                  progress: (@MainActor (Int, Int) -> Void)? = nil) async throws -> DecisionEvaluation
}
struct DecisionQuestion: Sendable, Hashable, Codable {     // Codable as Jev's {id, type, instructions, criteria}
    enum Kind { case noul, choice(criteria: [Criterion]), score(levels: [String]) }
    struct Criterion { var key: String; var description: String?; init(_ key: String, _ description: String? = nil) }
    var id: String; var instructions: String; var kind: Kind
    static func noul(_ id: String, _ instructions: String) -> DecisionQuestion
    static func choice(_ id: String, _ instructions: String, criteria: KeyValuePairs<String, String>) -> DecisionQuestion
    static func choice(_ id: String, _ instructions: String, criteria: [String]) -> DecisionQuestion
    static func score(_ id: String, _ instructions: String, criteria: [String]) -> DecisionQuestion
}
enum DecisionState { case text(String), json(String); static func encoding<T: Encodable>(_ value: T) throws -> DecisionState }
struct DecisionRequest { var state: DecisionState; var questions: [DecisionQuestion]; func validate(against: DecisionLimits = .jev) throws }
struct DecisionLimits { var maxQuestions, maxChoiceCriteria, maxScoreLevels: Int; var maxStateCharacters: Int?; static let jev }

// --- answers ----------------------------------------------------------------
struct Decision: Sendable, Hashable, Codable {
    var answers: [String: DecisionAnswer]
    var modelID: ModelID; var fidelity: DecisionFidelity; var latency: Duration; var usage: DecisionUsage?
    func noul(_ id: String) -> Double?            // P(yes)
    func choice(_ id: String) -> ChoiceAnswer?
    func score(_ id: String) -> ScoreAnswer?
}
enum DecisionAnswer { case noul(Double), choice(ChoiceAnswer), score(ScoreAnswer) }
struct ChoiceAnswer { var choice: String; var confidence: Double; var probabilities: [String: Double] }
struct ScoreAnswer { var score: Double; var confidence: Double; var probabilities: [Double]; var legend: [String]; var level: Int }
enum DecisionFidelity { case native, tokenScored(calibrated: Bool) }
struct DecisionUsage { var inputTokens: Int?; var outputTokens: Int?; var cost: Double? }
enum DecisionError: Error { case invalidRequest(String), noRoute(RouteName), unavailable(ModelID, ModelAvailability),
                            malformedAnswer(String), backend(String), pairing(String) }

// --- providers and routes ---------------------------------------------------
protocol DecisionProvider: Sendable {               // implement your own backend
    var scheme: String { get }
    var limits: DecisionLimits { get }               // default: .jev
    func fidelity(for id: ModelID) -> DecisionFidelity
    func availability(for id: ModelID) -> ModelAvailability
    func decide(_ request: DecisionRequest, using id: ModelID) async throws -> Decision
    func owns(_ id: ModelID) -> Bool                 // default: id.scheme == scheme
    func prewarm(_ id: ModelID) async                // default: nothing
}
extension ModelRegistry {
    func register(decision: any DecisionProvider) throws; func replace(decision: any DecisionProvider)
    func removeDecisionProvider(scheme: String)
    func route(decision: RouteName, to: ModelID); func modelID(forDecision: RouteName) -> ModelID?
    func decisionProvider(for: ModelID) -> (any DecisionProvider)?; func decisionAvailability(for: ModelID) -> ModelAvailability
    func pair(decision: RouteName, generator: RouteName) async throws -> DecisionPairing
}
struct DecisionPairing { var decider, generator: ModelID; var deciderBytes, generatorBytes, budgetBytes: Int64?
                         var residentModels: Int; var note: String? }
protocol PairableDecisionProvider: DecisionProvider { /* a local decider that can stay resident with a generator */ }

// --- OpenJev (LocalLMLabSDKInference, macOS 27) --------------------------------
struct OpenJevDecisionProvider: PairableDecisionProvider {     // scheme "openjev"; limits 64 questions, 26 options, 10 levels
    init(mlx: MLXModelProvider, wrapper: OpenJevWrapper = .default, calibration: DecisionCalibration? = nil, scheme: String = "openjev")
    init(mlx: MLXModelProvider, tunedWith set: DecisionQuestionSet, scheme: String = "openjev")
    let wrapper: OpenJevWrapper; let calibration: DecisionCalibration?; let tuning: DecisionQuestionSet.Tuning?
    func warnings(for id: ModelID) -> [OpenJevWarning]          // .mixtureOfExperts
    func tuningMismatch(for id: ModelID) -> String?             // not the model / revision the set was tested with
    func decideWithDiagnostics(_ request: DecisionRequest, using id: ModelID) async throws -> (decision: Decision, diagnostics: OpenJevDiagnostics)
}
struct OpenJevWrapper { var system: String; var inputLabel: String; static let `default` }
struct OpenJevDiagnostics { let questions: [String: Question]; let sharedPrefixTokens: Int }   // Question: labels, prompt, labelMass, latency

// --- hosted Jev (LocalLMLabSDKRemote) -------------------------------------------
struct JevDecisionProvider: DecisionProvider { init(_ config: JevProviderConfig) }
struct JevProviderConfig {   // scheme, displayName, endpoint, auth, models, allowArbitraryModelIDs, fidelity, limits, responseLimits, timeout
    static func openRouter(key: String) -> JevProviderConfig     // TypeSafe Jev, .native
    static func featherless(key: String) -> JevProviderConfig    // .tokenScored(calibrated: false)
    static func featherlessDemo() -> JevProviderConfig           // no key, rate-limited, maxStateCharacters 6000
    func validated() throws -> JevProviderConfig
}

// --- hosted Jev's wire format (both directions; a server can answer the same API) ---
enum JevWire {
    struct Request { var model: String?; var request: DecisionRequest }
    struct Response { var model: String?; var answers: [String: DecisionAnswer]; var usage: DecisionUsage?
                      var fidelity: DecisionFidelity?; var errorMessage: String? }
    static func encodeRequest(_ request: DecisionRequest, model: String) throws -> Data
    static func decodeRequest(_ data: Data) throws -> Request            // order-preserving
    static func encodeResponse(_ decision: Decision, model: String, request: DecisionRequest) throws -> Data
    static func decodeResponse(_ data: Data, request: DecisionRequest) throws -> Response
    static func encodeError(message: String, type: String, code: Int) -> Data
    static func errorMessage(in data: Data) -> String?
}

// --- calibration, question sets, answer sets, evaluation ---------------------------
struct DecisionCalibration: Codable {
    var noulTemperature, choiceTemperature, scoreTemperature: Double; static let none
    struct Sample { var kind: Kind; var probabilities: [Double]; var correct: [Int] }   // Kind: .noul, .choice, .score
    struct Measurement { var count: Int; var accuracy, expectedCalibrationError, logLoss: Double }
    static func fit(_ samples: [Sample]) -> DecisionCalibration
    func measure(_ samples: [Sample]) -> Measurement
    func apply(_ probabilities: [Double], kind: Sample.Kind) -> [Double]
}
struct DecisionQuestionSet: Codable {                   // JevDK's "Export Questions…"
    static let currentFormat: Int
    var format: Int; var name: String; var questions: [DecisionQuestion]; var tuning: Tuning?
    struct Tuning { var model: ModelID?; var revision, system, inputLabel: String?; var calibration: DecisionCalibration?; var testedAt: Date? }
    init(name: String, questions: [DecisionQuestion], tuning: Tuning? = nil)
    init(contentsOf url: URL) throws; init(json: Data) throws; func jsonData() throws -> Data
}
struct DecisionAnswerSet: Codable {                     // CSV: input, then one column per question id
    struct Example { var input: String; var answers: [String: String] }
    var examples: [Example]
    init(csv: String) throws; func csv(questionIDs: [String]) -> String
    func problems(for questions: [DecisionQuestion]) -> [String]
    static func answerKey(_ raw: String, for q: DecisionQuestion) -> String?
}
struct DecisionEvaluation {
    var modelID: ModelID; var fidelity: DecisionFidelity; var questions: [QuestionScore]; var misses: [Miss]
    var samples: [DecisionCalibration.Sample]; var decisions: Int; var totalLatency: Duration
    var accuracy: Double; var calibration: DecisionCalibration.Measurement
    func score(_ questionID: String) -> QuestionScore?   // marked, correct, accuracy, meanConfidenceWhenRight / WhenWrong
}

Tool authority (ToolCallAuthorizer, ToolImpact, confirmation)

The two levers from §7c, as types. Everything here is in LocalLMLabSDKCore except the last two, in LocalLMLabSDKComponents.

// --- lever 1: selection --------------------------------------------------
extension Sequence where Element == any Tool {
    func limited(toMaxImpact ceiling: ToolImpact) -> [any Tool]   // drops tools above ceiling; unrated = .mutate
}
protocol ImpactRatedTool: Tool { var impact: ToolImpact { get } }   // every ready-made SDK tool conforms
enum ToolImpact: Int, Sendable, Codable, Comparable, CaseIterable { case read, mutate, destructive }
protocol OriginTaggedTool: Tool { var toolOrigin: ToolOrigin { get } }   // a host's own MCP-backed Tool conforms to keep .mcp origin

// --- lever 2: invocation --------------------------------------------------
protocol ToolCallAuthorizer: Sendable {
    func authorize(_ call: PendingToolCall) async -> ToolCallDecision
}
enum ToolCallDecision: Sendable {
    case allow
    case deny(reason: String)   // -> the model sees tool result "DENIED: <reason>" (String-output tools)
}
struct PendingToolCall: Sendable {
    let toolName: String
    let arguments: GeneratedContent?       // structured; nil only for a rare third-party From-only Arguments
    let argumentsDescription: String       // always present
    let origin: ToolOrigin                 // .host | .mcp(server:displayName:)
    let impact: ToolImpact                 // MCP: .mutate unless the server is trusted (then its annotations)
    let initiator: ToolCallInitiator       // .model | .app(instance:) (an MCP App view) | .host
    let serverApproval: ToolApproval?      // MCP: the server's approval at call time, filled by the SDK; nil for your own tools
    init(toolName: String, arguments: GeneratedContent?, argumentsDescription: String, origin: ToolOrigin, impact: ToolImpact,
         initiator: ToolCallInitiator = .model, serverApproval: ToolApproval? = nil)
    var summary: PendingToolCallSummary { get }   // the Codable projection, for crossing a process boundary
}
struct PendingToolCallSummary: Codable, Sendable, Equatable {   // a missing initiator decodes as .model, serverApproval as nil
    let toolName: String; let argumentsDescription: String; let origin: ToolOrigin; let impact: ToolImpact
    let initiator: ToolCallInitiator; let serverApproval: ToolApproval?
    init(toolName: String, argumentsDescription: String, origin: ToolOrigin, impact: ToolImpact,
         initiator: ToolCallInitiator = .model, serverApproval: ToolApproval? = nil)
}
enum ToolOrigin: Sendable, Codable, Equatable { case host; case mcp(server: MCPServerID, displayName: String) }
enum ToolCallInitiator: Sendable, Hashable, Codable { case model; case app(instance: String); case host }

// --- per-server trust and approval (new in 2.0; set on lab.mcp — see "MCP client" below) ---
enum MCPServerTrust: String, Sendable, Codable, CaseIterable { case untrusted, trusted }   // trusted ⇒ annotations set impact
enum ToolApprover: String, Sendable, Codable { case user, policy }
enum ToolApproval: Sendable, Codable, Equatable {
    case allow                                            // every call runs
    case ask(atOrAbove: ToolImpact, by: ToolApprover)     // calls at/above the impact need authorization by `by`
    static func `default`(for trust: MCPServerTrust) -> ToolApproval   // untrusted: .ask(.mutate, .user); trusted: .ask(.destructive, .user)
}
typealias ToolPolicy = @Sendable (PendingToolCall) async -> ToolPolicyDecision   // your external authorization function
enum ToolPolicyDecision: Sendable, Equatable { case allow; case deny(reason: String); case askUser }

// makeSession(..., authorizer:) — [§12](#12-full-functiontype-reference) "The model layer" — installs the gate; nil = unchanged, no gate.

protocol ToolConfirmationChannel: Sendable {   // the transport an "ask the user" decision suspends on
    func requestDecision(for call: PendingToolCall) async -> Bool
}
struct ConfirmingToolAuthorizer: ToolCallAuthorizer {   // the SDK's one authorizer
    // Per call: the MCP server's approval (call.serverApproval; nil ⇒ the untrusted default) or `hostTools`
    // for your own tools; below the threshold runs, .ask(by: .user) asks `channel`, .ask(by: .policy) asks
    // `policy` (which may hand it to the user). Fails closed: no channel / no policy / timeout / declined ⇒ deny.
    // A view's (initiator .app) answer is remembered per view, server and tool; .destructive and timeouts aren't.
    init(channel: (any ToolConfirmationChannel)?, policy: ToolPolicy? = nil,
         hostTools: ToolApproval = .ask(atOrAbove: .mutate, by: .user), timeout: Duration = .seconds(120))
}
final class DecisionGate: @unchecked Sendable {   // guards the resume-once race between a reply and the timeout
    init()
    func attach(_ continuation: CheckedContinuation<Bool, Never>)
    func resume(_ value: Bool)
}

// --- LocalLMLabSDKComponents: the reference ToolConfirmationChannel + SwiftUI sheet ---
final class ToolConfirmationPresenter: ObservableObject, ToolConfirmationChannel {
    init(fallbackTimeout: Duration = .seconds(120))
    func resolve(_ id: ToolConfirmationRequest.ID, allow: Bool)
}
struct ToolConfirmationRequest: Identifiable, Sendable { let id: UUID; let call: PendingToolCall }
// View.toolConfirmationSheet(_ presenter: ToolConfirmationPresenter) — attach once, near the root

Connectors (Calendar, Reminders, Contacts, Location)

// Unified facade — permission lifecycle only, identical shape across all four
enum Connector: String, CaseIterable, Sendable { case calendar, reminders, contacts, location }
struct ConnectorAccessResult: Sendable { var granted: Bool; var error: String?; var needsSystemSettings: Bool }
enum Connectors {
    static func isAuthorized(_ connector: Connector) -> Bool
    static func requestAccess(_ connector: Connector) async -> ConnectorAccessResult
    static func openSystemSettings(for connector: Connector)
}

// CalendarAccess
enum CalendarAccess {
    static var store: EKEventStore { get }  // raw EventKit store, for anything the wrapper below doesn't cover; refreshed internally after a fresh grant
    static var authorizationStatus: EKAuthorizationStatus { get }
    static var isAuthorized: Bool { get }
    struct AccessResult { var granted: Bool; var error: String?; var needsSystemSettings: Bool }
    static func requestAccess() async -> AccessResult
    static func openSystemSettings()
    struct EventSummary: Codable { var eventIdentifier: String; var title: String; var start: String; var end: String; var calendar: String; var location: String?; var isAllDay: Bool }
    static func upcomingEvents(days: Int) -> [EventSummary]
    struct AddEventResult { var success: Bool; var error: String? }
    static func addEvent(title: String, start: Date, end: Date) -> AddEventResult
    struct MutateEventResult { var success: Bool; var error: String? }
    // locates the event by title + the day it's currently on, not eventIdentifier; nil new* parameters leave that field unchanged
    static func updateEvent(title: String, onDate: DateComponents, newTitle: String?, newDate: DateComponents?, newTime: DateComponents?, durationMinutes: Int?) -> MutateEventResult
    static func deleteEvent(title: String, onDate: DateComponents) -> MutateEventResult
}

// RemindersAccess — same shape as CalendarAccess, also backed by EventKit (EKEventStore
// handles both calendar events and reminders)
enum RemindersAccess {
    static var store: EKEventStore { get }
    static var authorizationStatus: EKAuthorizationStatus { get }
    static var isAuthorized: Bool { get }
    struct AccessResult { var granted: Bool; var error: String?; var needsSystemSettings: Bool }
    static func requestAccess() async -> AccessResult
    static func openSystemSettings()
    struct ReminderSummary: Codable, Sendable { var calendarItemIdentifier: String; var title: String; var dueDate: String?; var list: String; var isCompleted: Bool; var priority: Int }
    static func upcomingReminders(days: Int) async -> [ReminderSummary]
    struct AddReminderResult { var success: Bool; var error: String? }
    static func addReminder(title: String, dueDateComponents: DateComponents?) -> AddReminderResult
    struct MutateReminderResult { var success: Bool; var error: String? }
    // locates the reminder by title, optionally narrowed by its current due date; nil new* parameters leave that field unchanged
    static func updateReminder(title: String, dueDate: DateComponents?, newTitle: String?, newDate: DateComponents?, newTime: DateComponents?, isCompleted: Bool?) async -> MutateReminderResult
    static func deleteReminder(title: String, dueDate: DateComponents?) async -> MutateReminderResult
}

// ContactsAccess
enum ContactsAccess {
    static var store: CNContactStore { get }  // raw Contacts store, for anything the wrapper below doesn't cover; refreshed internally after a fresh grant
    static var authorizationStatus: CNAuthorizationStatus { get }
    static var isAuthorized: Bool { get }
    struct AccessResult { var granted: Bool; var error: String?; var needsSystemSettings: Bool }
    static func requestAccess() async -> AccessResult
    static func openSystemSettings()
    struct ContactSummary: Codable { var identifier: String; var name: String; var givenName: String; var familyName: String?; var organization: String?; var phoneNumbers: [String]; var emails: [String] }
    static func search(query: String, limit: Int) -> [ContactSummary]
    static func list(limit: Int) -> [ContactSummary]
    struct MutateContactResult { var success: Bool; var error: String? }
    static func addContact(givenName: String, familyName: String, organization: String?, phoneNumbers: [String], emails: [String]) -> MutateContactResult
    // locates the contact by givenName, optionally narrowed by its current familyName; nil new* parameters leave that field unchanged, non-nil newPhoneNumbers/newEmails replace the contact's entire existing list
    static func updateContact(givenName: String, familyName: String?, newGivenName: String?, newFamilyName: String?, newOrganization: String?, newPhoneNumbers: [String]?, newEmails: [String]?) -> MutateContactResult
    static func deleteContact(givenName: String, familyName: String?) -> MutateContactResult
}

// LocationAccess — a class (LocationAccess.shared), not a static enum, since it holds
// CLLocationManagerDelegate state
final class LocationAccess {
    static let shared: LocationAccess
    var authorizationStatus: CLAuthorizationStatus { get }
    var isAuthorized: Bool { get }
    var lastFetchFailureReason: String? { get }
    struct AccessResult: Sendable { var granted: Bool; var error: String?; var needsSystemSettings: Bool }
    func requestAccess() async -> AccessResult
    static func openSystemSettings()
    struct LocationSummary: Codable { var latitude: Double; var longitude: Double; var horizontalAccuracyMeters: Double; var timestamp: String; var placeName: String? }
    func currentLocation() async -> LocationSummary?
}

Ready-made connector Tools (Path A)

Thin Tool-conforming wrappers around the connectors above — see §7a (Path A vs Path B) for the framing. Each takes an optional custom description at init, same pattern as ClockTool/WeatherTool below.

// Calendar
struct GetUpcomingEventsTool: Tool {
    let name = "getUpcomingEvents"
    init(description: String? = nil)
    struct Arguments { var days: Int? }             // defaults to 7, capped at 30
}
struct AddCalendarEventTool: Tool {
    let name = "addCalendarEvent"
    init(description: String? = nil)
    struct Arguments { var title: String; var date: String; var time: String; var durationMinutes: Int? }
}
struct UpdateCalendarEventTool: Tool {
    let name = "updateCalendarEvent"
    init(description: String? = nil)
    // currentDate locates the event; each new* field is independently optional
    struct Arguments { var title: String; var currentDate: String; var newTitle: String?; var newDate: String?; var newTime: String?; var durationMinutes: Int? }
}
struct DeleteCalendarEventTool: Tool {
    let name = "deleteCalendarEvent"
    init(description: String? = nil)
    struct Arguments { var title: String; var currentDate: String }
}

// Reminders — same title/currentDate/new* shape as Calendar; currentDate is optional here
// (a reminder may have no due date, unlike a Calendar event)
struct GetUpcomingRemindersTool: Tool {
    let name = "getUpcomingReminders"
    init(description: String? = nil)
    struct Arguments { var days: Int? }
}
struct AddReminderTool: Tool {
    let name = "addReminder"
    init(description: String? = nil)
    struct Arguments { var title: String; var date: String?; var time: String? }
}
struct UpdateReminderTool: Tool {
    let name = "updateReminder"
    init(description: String? = nil)
    struct Arguments { var title: String; var currentDate: String?; var newTitle: String?; var newDate: String?; var newTime: String?; var isCompleted: Bool? }
}
struct DeleteReminderTool: Tool {
    let name = "deleteReminder"
    init(description: String? = nil)
    struct Arguments { var title: String; var currentDate: String? }
}

// Contacts — currentGivenName/currentFamilyName locate the contact; each new* field is
// independently optional. newPhoneNumbers/newEmails, when provided, replace the entire list.
struct SearchContactsTool: Tool {
    let name = "searchContacts"
    init(description: String? = nil)
    struct Arguments { var query: String; var limit: Int? }  // defaults to 10, capped at 50
}
struct ListContactsTool: Tool {
    let name = "listContacts"
    init(description: String? = nil)
    struct Arguments { var limit: Int? }                     // defaults to 50, capped at 200
}
struct AddContactTool: Tool {
    let name = "addContact"
    init(description: String? = nil)
    struct Arguments { var givenName: String; var familyName: String?; var organization: String?; var phoneNumbers: [String]?; var emails: [String]? }
}
struct UpdateContactTool: Tool {
    let name = "updateContact"
    init(description: String? = nil)
    struct Arguments { var currentGivenName: String; var currentFamilyName: String?; var newGivenName: String?; var newFamilyName: String?; var newOrganization: String?; var newPhoneNumbers: [String]?; var newEmails: [String]? }
}
struct DeleteContactTool: Tool {
    let name = "deleteContact"
    init(description: String? = nil)
    struct Arguments { var currentGivenName: String; var currentFamilyName: String? }
}

// Location — the one connector Tool with no writable counterpart
struct GetCurrentLocationTool: Tool {
    let name = "getCurrentLocation"
    init(description: String? = nil)
    struct Arguments {}
}

Filesystem workspace (WorkspaceAccess/WorkspaceTools)

Not a connector — no requestAccess(), no OS permission dialog. Operates on a root URL your app already resolved via a security-scoped bookmark (§8 — Filesystem access); the picker itself is the one-time consent. WorkspaceAccess is the raw data layer; WorkspaceTools are the matching Path A Tools, each taking the resolved root URL at init.

enum WorkspaceAccess {
    struct WorkspaceError: Error { var message: String }
    struct WorkspaceEntry: Codable, Sendable { var name: String; var isDirectory: Bool; var modifiedDate: Date?; var size: Int? }
    struct SearchMatch: Codable, Sendable, Equatable { var path: String; var line: Int; var text: String }
    struct PatchResult: Codable, Sendable, Equatable {
        var changed: [String]; var created: [String]; var deleted: [String]; var hunksApplied: Int
    }

    static func listFiles(in root: URL, subpath: String?) -> Result<[WorkspaceEntry], WorkspaceError>
    static func readFile(in root: URL, path: String) -> Result<String, WorkspaceError>
    // create-by-default — fails on an existing file unless overwrite:true (or append:true to add to the end); use editFile for a partial change
    static func writeFile(in root: URL, path: String, contents: String, overwrite: Bool = false, append: Bool = false) -> Result<Void, WorkspaceError>
    // search-and-replace, not a unified-diff format — oldString must match exactly once unless replaceAll
    static func editFile(in root: URL, path: String, oldString: String, newString: String, replaceAll: Bool) -> Result<Void, WorkspaceError>
    static func deleteFile(in root: URL, path: String) -> Result<Void, WorkspaceError>
    // unified-diff patch, potentially touching several files under root in one call — behind ApplyPatchTool
    static func applyPatch(in root: URL, diff: String) -> Result<PatchResult, WorkspaceError>
    // plain-text or regex; behind SearchWorkspaceTool
    static func search(in root: URL, query: String, isRegex: Bool = false, include: String? = nil, maxResults: Int = 200) -> Result<[SearchMatch], WorkspaceError>
    // an indented directory listing; behind WorkspaceTreeTool
    static func tree(in root: URL, subpath: String? = nil, maxDepth: Int = 2) -> Result<String, WorkspaceError>
    // a byte/line window into a large file without reading it whole; behind ReadFileRangeTool
    static func readFileRange(in root: URL, path: String, offset: Int, limit: Int) -> Result<String, WorkspaceError>
}

struct ListWorkspaceFilesTool: Tool {
    let name = "listWorkspaceFiles"
    init(root: URL, description: String? = nil)
    struct Arguments { var path: String? }
}
struct ReadWorkspaceFileTool: Tool {
    let name = "readWorkspaceFile"
    init(root: URL, description: String? = nil)
    struct Arguments { var path: String }
}
struct WriteWorkspaceFileTool: Tool {
    let name = "writeWorkspaceFile"
    init(root: URL, description: String? = nil)
    struct Arguments { var path: String; var contents: String; var overwrite: Bool?; var append: Bool? }
}
struct EditWorkspaceFileTool: Tool {
    let name = "editWorkspaceFile"
    init(root: URL, description: String? = nil)
    struct Arguments { var path: String; var oldString: String; var newString: String; var replaceAll: Bool? }
}
// Not included in workspace-buddy's default tool list — see that example's README. Available
// here for a host app that explicitly wants a delete-capable coding assistant.
struct DeleteWorkspaceFileTool: Tool {
    let name = "deleteWorkspaceFile"
    init(root: URL, description: String? = nil)
    struct Arguments { var path: String }
}
struct SearchWorkspaceTool: Tool {
    let name = "searchWorkspace"
    init(root: URL, description: String? = nil)
    struct Arguments { var query: String; var isRegex: Bool?; var include: String? }
}
struct WorkspaceTreeTool: Tool {
    let name = "workspaceTree"
    init(root: URL, description: String? = nil)
    struct Arguments { var path: String?; var maxDepth: Int? }
}
struct ApplyPatchTool: Tool {
    let name = "applyPatch"
    init(root: URL, description: String? = nil)
    struct Arguments { var diff: String }
}
struct ReadFileRangeTool: Tool {
    let name = "readFileRange"
    init(root: URL, description: String? = nil)
    struct Arguments { var path: String; var offset: Int; var limit: Int }
}

// The "AIQL" data verbs — each init(root:description:), reads one workspace file, writes a CSV
// (see [§8b](#8b-filebackedtool--the-aiql-data-verbs-a-mechanical-mcp-dataset--csv-pipeline) for the full table and pipeline shape). CSVCodec + JSONPath are public building blocks.
struct JSONToCSVTool: Tool     { let name = "jsonToCsv" }      // SELECT cols FROM json_array
struct SelectColumnsTool: Tool { let name = "selectColumns" }  // SELECT a AS x, b
struct FilterRowsTool: Tool    { let name = "filterRows" }     // WHERE
struct SortRowsTool: Tool      { let name = "sortRows" }       // ORDER BY … LIMIT  (the mechanical "top N")
struct DedupeRowsTool: Tool    { let name = "dedupeRows" }     // SELECT DISTINCT
struct AggregateRowsTool: Tool { let name = "aggregateRows" }  // GROUP BY
struct ConcatRowsTool: Tool    { let name = "concatRows" }     // UNION ALL
struct DescribeJSONTool: Tool  { let name = "describeJson" }   // structure summary (read-only)
struct CSVInfoTool: Tool       { let name = "csvInfo" }        // row/column counts + samples (read-only)
struct LoadTableTool: Tool     { let name = "loadTable" }     // JSON records -> an ephemeral SQLite table
struct SQLQueryTool: Tool      { let name = "sqlQuery" }      // one read-only SELECT -> CSV

// Host-applied decorator: wraps a dynamic-schema tool, adds a root-level `saveAs` that writes the
// wrapped tool's raw result to a workspace file instead of returning it. [§8b](#8b-filebackedtool--the-aiql-data-verbs-a-mechanical-mcp-dataset--csv-pipeline).
struct FileBackedTool: Tool {
    typealias Arguments = GeneratedContent
    init(name: String, description: String, argumentsJSONSchema: Data, root: URL,
         previewCharacters: Int = 600, inlineCharacterLimit: Int? = nil,
         invoke: @escaping @Sendable (GeneratedContent) async -> String) throws
    static func mcp(descriptor: MCPToolDescriptor, manager: MCPServerManager, root: URL,
                    previewCharacters: Int = 600, inlineCharacterLimit: Int? = nil) throws -> FileBackedTool
}

No-permission tools

struct ClockTool: Tool {
    static let defaultDescription: String
    init(description: String? = nil)
    // Arguments: zero properties — call() returns the current date/time as text
}

struct WeatherTool: Tool {
    static let defaultDescription: String
    init(description: String? = nil)
    struct Arguments { var location: String }
    // call() returns current conditions + 7-day forecast via Open-Meteo
}

Error handling

enum GenerationErrorDescription {
    // Turns a LanguageModelSession.GenerationError into a readable, per-case explanation
    // (falls back to error.localizedDescription for any other error type). async because
    // .refusal's explanation is itself computed asynchronously by FoundationModels.
    static func describe(_ error: Error) async -> String
}

A note on what's not listed above: @Generable (Apple's FoundationModels macro, applied to every Arguments struct in this SDK and in your own tools) synthesizes additional public members on each one — a PartiallyGenerated nested type, generationSchema, generatedContent, and a few others. These are FoundationModels' own machinery for incremental/streaming generation, not SDK API — you'll never call them directly, only @Generable/LanguageModelSession do. Left out here deliberately, the same way compiler-synthesized Codable/Hashable methods (init(from:), encode(to:), hash(into:)) are left out of the reference types above — real public symbols, but noise for this list's purpose. If you inspect the compiled binary directly and see these, that's expected, not a sign this reference is out of date.

MCP client

final class MCPServerManager {
    init(responseLimits: MCPResponseLimits = .default, handlers: MCPClientHandlers = .init(),
         versionNegotiation: MCPVersionNegotiation = .auto, liveUpdates: Bool = true)  // NOT a singleton
    private(set) var servers: [MCPServerID: MCPServerState] { get }
    var serverChanges: AsyncStream<[MCPServerID: MCPServerState]> { get }
    var estimatedTotalTokens: Int { get }

    func addServer(url: URL, displayName: String, authType: MCPAuthType = .none, patToken: String? = nil, manualClientID: String? = nil) async -> Result<MCPServerState, MCPServerError>
    func reconnect(_ id: MCPServerID) async -> Result<MCPServerState, MCPServerError>
    func disconnect(_ id: MCPServerID)
    func removeServer(_ id: MCPServerID)
    // drops every in-memory server without touching Keychain tokens — pair with restore(from:)
    // for a config-profile Load, where removeServer's per-server credential clear is the wrong call
    func removeAllKeepingCredentials()
    func restore(from persisted: [(id: MCPServerID, url: URL, displayName: String, tools: [MCPToolDescriptor], estimatedTokens: Int, enabled: Bool, authType: MCPAuthType, manualClientID: String?, resources: [(uri: String, name: String, enabled: Bool)])])

    func toolsForSession() -> [MCPToolDescriptor]
    func setToolEnabled(server: MCPServerID, tool: String, enabled: Bool)
    // Per-server trust and approval ([§7c](#7c-tool-authorization-two-levers--which-tools-and-whether-they-ask-first), new in 2.0).
    // Kept across reconnects, cleared by removeServer; not persisted — save the state's trust / toolApproval and set them again after restore.
    func setTrust(_ trust: MCPServerTrust, server: MCPServerID)                // .trusted ⇒ its annotations set each tool's impact
    func setToolApproval(_ approval: ToolApproval?, server: MCPServerID)       // nil ⇒ ToolApproval.default(for: trust)
    func toolApproval(server: MCPServerID) -> ToolApproval                     // the effective approval, read on every call
    // -> MCPToolResult ([§3b](#3b-what-a-tool-call-gives-you-back-mcptoolresult)), not a String. A tool that ran but failed is .success with isError == true.
    // allowElicitation: false on a code path that can't show a prompt (headless / background).
    func callTool(server: MCPServerID, tool: String, arguments: [String: MCPValue], allowElicitation: Bool = true) async -> Result<MCPToolResult, MCPServerError>

    func resourcesForSession() -> [MCPResourceDescriptor]
    func resourceTemplatesForSession() -> [MCPResourceTemplateDescriptor]
    func setResourceEnabled(server: MCPServerID, uri: String, enabled: Bool)
    func readResource(server: MCPServerID, uri: String) async -> Result<MCPResourceContent, MCPServerError>

    func promptsForSession() -> [MCPPromptDescriptor]
    func getPrompt(server: MCPServerID, name: String, arguments: [String: String]) async -> Result<[MCPPromptMessage], MCPServerError>
}

struct MCPServerID: Hashable, Codable, Sendable {
    let rawValue: String
    init(rawValue: String)
}

struct MCPServerState: Codable, Sendable {
    var id: MCPServerID
    var url: URL
    var displayName: String
    var connectionStatus: MCPConnectionStatus
    var tools: [MCPToolDescriptor]
    var estimatedTokens: Int
    var enabled: Bool
    var authType: MCPAuthType
    var patTokenLastFour: String?       // masked display only, never the real token
    var manualClientID: String?
    var resources: [MCPResourceDescriptor]
    var resourceTemplates: [MCPResourceTemplateDescriptor]
    var prompts: [MCPPromptDescriptor]
    var negotiatedProtocolVersion: MCPProtocolVersion?   // nil until connected ([§3a](#3a-which-mcp-revision-the-client-speaks--and-why-you-mostly-dont-have-to-care))
    var serverInstructions: String?                       // server's own usage note (initialize or server/discover)
    var liveUpdates: MCPLiveUpdates?                      // stateless servers only; nil otherwise ([§3a](#3a-which-mcp-revision-the-client-speaks--and-why-you-mostly-dont-have-to-care))
    var trust: MCPServerTrust                             // setTrust (default .untrusted)
    var toolApproval: ToolApproval?                       // setToolApproval's value; nil = default for the trust level
    var trustedToolsDigest: String?                       // the tool list when last trusted
    var toolsChangedSinceTrusted: Bool { get }            // a trusted, connected server whose tools changed — show it for review
    func exportSummary() -> String      // plain-text listing of tools/resources/prompts, enabled state, token cost
}

enum MCPProtocolVersion: String, CaseIterable, Comparable, Sendable, Codable {
    @available(*, deprecated) case v2024_11_05 = "2024-11-05"   // no longer negotiated; kept so stored values decode
    case v2025_03_26 = "2025-03-26", v2025_06_18 = "2025-06-18"
    case v2025_11_25 = "2025-11-25", v2026_07_28 = "2026-07-28"
    static let clientPreferred: MCPProtocolVersion = .v2025_11_25      // offered at initialize (handshake era)
    static let statelessPreferred: MCPProtocolVersion = .v2026_07_28   // probed with server/discover
    static let minimumSupported: MCPProtocolVersion = .v2025_03_26     // lower → .protocolMismatch
    var isStateless: Bool { get }                         // >= 2026-07-28
    var supportsStructuredContent: Bool { get }           // >= 2025-06-18
    var supportsElicitation: Bool { get }                 // >= 2025-06-18
    var supportsIconsAndRicherElicitation: Bool { get }   // >= 2025-11-25
    var supportsURLElicitation: Bool { get }              // >= 2025-11-25
    var sendsProtocolVersionHeader: Bool { get }          // >= 2025-06-18
}

// How connect() chooses between the stateless and handshake models (mirrors the TS SDK's option).
enum MCPVersionNegotiation: Sendable, Equatable {
    case auto                       // default: server/discover, fall back to initialize
    case legacy                     // initialize only
    case pin(MCPProtocolVersion)    // stateless at this revision, no fallback
}

// Live updates (2026-07-28 subscriptions/listen). The manager runs these for you; read
// MCPServerState.liveUpdates for status. Opt out with MCPServerManager(liveUpdates: false).
enum MCPLiveUpdates: Sendable, Codable, Equatable {
    case active(MCPSubscriptionFilter)   // listening; what the server granted
    case reconnecting                    // stream dropped, retrying with backoff
    case unavailable(String)             // refused or sign-in expired; resumes on next connect
}
struct MCPSubscriptionFilter: Sendable, Codable, Equatable {
    var toolsListChanged: Bool; var promptsListChanged: Bool; var resourcesListChanged: Bool
}

enum MCPAuthType: String, Codable, Sendable { case none, pat, oauthManual }
enum MCPConnectionStatus: String, Codable, Sendable { case connected, disconnected, connecting, failed }
// MCPConnectionStatus and MCPServerError are non-frozen (1.0) — an exhaustive switch needs
// `@unknown default`. This is the only source-break for an MCP-only 0.8.x consumer; see
// migrating-to-1.0.md.

struct MCPToolDescriptor: Codable, Sendable {
    var serverID: MCPServerID
    var name: String             // model-facing identifier
    var description: String
    var rawSchema: Data          // the tool's real JSON schema — inspect before assuming params
    var estimatedTokens: Int
    var enabled: Bool            // defaults to false for newly-discovered tools
    var title: String?           // human display name for UI (2025-06-18+); fall back to name
    var outputSchema: Data?      // declared shape of structuredContent, if any (2025-06-18+)
    var meta: MCPValue?          // the tool's _meta
    var annotations: MCPToolAnnotations?   // readOnlyHint / destructiveHint …: the server's claim (believed only for a trusted server)
    var app: MCPAppLink? { get }           // its MCP App view (_meta.ui.resourceUri), if any
    var isModelVisible: Bool { get }       // false for app-only tools: never offered to a model
}
struct MCPAppLink: Sendable, Equatable, Hashable { var resourceURI: String; var visibility: Set<MCPAppVisibility> }
enum MCPAppVisibility: String, Sendable, Codable, CaseIterable { case model, app }
struct MCPToolAnnotations: Codable, Sendable, Equatable {
    var title: String?; var readOnlyHint: Bool?; var destructiveHint: Bool?; var idempotentHint: Bool?; var openWorldHint: Bool?
}

struct MCPResourceDescriptor: Codable, Sendable {
    var serverID: MCPServerID
    var uri: String
    var name: String
    var description: String
    var mimeType: String?
    var enabled: Bool            // defaults to true
    var meta: MCPValue?
    var isMCPAppResource: Bool { get }   // a ui:// view's HTML: left out of model-facing resource lists
}

struct MCPResourceTemplateDescriptor: Codable, Sendable {
    var serverID: MCPServerID
    var uriTemplate: String      // e.g. "res://{id}" — resolve placeholders yourself before reading
    var name: String
    var description: String
    var mimeType: String?
}

struct MCPResourceContent: Codable, Sendable {
    var uri: String
    var mimeType: String?
    var text: String?            // exactly one of text/blob is non-nil, per MCP spec
    var blob: String?
}

struct MCPPromptArgument: Codable, Sendable { var name: String; var description: String; var required: Bool }
struct MCPPromptDescriptor: Codable, Sendable {
    var serverID: MCPServerID
    var name: String
    var description: String
    var arguments: [MCPPromptArgument]
}
struct MCPPromptMessage: Codable, Sendable { var role: String; var text: String }

indirect enum MCPValue: Codable, Sendable {
    case string(String), number(Double), bool(Bool), array([MCPValue]), object([String: MCPValue]), null
    func strippingEmpty() -> MCPValue?      // drop ""/[]/null and objects that collapse — the
                                            // "did the user/model actually supply this?" rule
}

// What callTool gives you back ([§3b](#3b-what-a-tool-call-gives-you-back-mcptoolresult)).
struct MCPToolResult: Sendable, Codable, Equatable {
    var text: String
    var structuredContent: MCPValue?        // JSON the server marked structured; validated vs outputSchema
    var resourceLinks: [MCPResourceLink]
    var isError: Bool                        // the tool ran and reported failure
    var truncated: Bool                      // response hit MCPResponseLimits
    var meta: MCPValue?                      // the result's _meta
    var renderedForModel: String { get }     // compact, context-sized rendering folding in all of the above
}
struct MCPResourceLink: Sendable, Codable, Equatable {
    var uri: String; var name: String?; var title: String?; var description: String?; var mimeType: String?
}

enum MCPServerError: Error, Codable, Sendable {
    case unreachable
    case malformedResponse
    case protocolMismatch
    case notConnected
    case toolNotFound
    case serverError(String)                // includes real HTTP status/URL/body where available
    case authorizationRequired               // OAuth flow needed and didn't complete
    case credentialRejected                  // PAT rejected — no retry path but a new token
    case httpError(Int)
    case oauthRegistrationNotSupported       // no DCR — retry with .oauthManual + manualClientID
    case responseTooLarge(String)            // server response exceeded MCPResponseLimits
}

Server-initiated request handlers (§3c) — all optional; a capability is advertised to a server only when its handler is non-nil:

struct MCPClientHandlers: Sendable {
    init(elicitation: (any MCPElicitationHandler)? = nil,
         sampling:    (any MCPSamplingHandler)?    = nil,
         roots:       (any MCPRootsProvider)?      = nil,
         logging:     (any MCPLoggingSink)?        = nil,
         extensions:  [String: MCPValue]           = [:])   // client capability extensions declared at initialize
    var extensions: [String: MCPValue]
}
// LocalLMLabSDKMCPAppsHost: MCPClientHandlers().advertisingMCPApps() — declares MCP Apps support
// (extensions["io.modelcontextprotocol/ui"]), which servers check before offering views.

protocol MCPElicitationHandler: Sendable {
    func handleElicitation(_ request: MCPElicitationRequest) async -> MCPElicitationResponse
}
struct MCPElicitationRequest: Sendable, Equatable {
    var message: String
    var rawSchema: Data                       // the raw requestedSchema, for custom rendering
    var fields: [MCPElicitationField]         // flattened: string(format)/number/integer/boolean/enum
    var url: URL?                             // URL-mode elicitation (2025-11-25)
    var serverName: String?                   // which server is asking — show this
    var serverURL: URL?
}
enum MCPElicitationResponse: Sendable, Equatable { case accept([String: MCPValue]), decline, cancel }

protocol MCPSamplingHandler: Sendable {      // your handler owns the human-in-the-loop approval
    func handleSampling(_ request: MCPSamplingRequest) async -> MCPSamplingResponse?
}
protocol MCPRootsProvider: Sendable { func roots() async -> [MCPRoot] }
protocol MCPLoggingSink: Sendable { func receive(_ message: MCPLogMessage) }

Diagnostics (§3e) — off-content, secrets redacted:

enum MCPDiagnostics {
    static var logLevel: MCPLogLevel { get set }     // default .info
    static func setEnabled(_ enabled: Bool)          // in-memory ring buffer, default off
    static var capacity: Int { get set }             // default 500
    static var observer: (@Sendable (MCPDiagnosticEvent) -> Void)? { get set }
    static func exportText() -> String
    static func exportJSON() -> Data
    static func clear()
}
enum MCPLogLevel: Int, Sendable, Comparable, Codable { case debug, info, notice, error, off }

OAuth setup (§3's OAuth redirect URI + AppDelegate wiring sections):

enum MCPOAuthFlow {
    static var redirectURI: String { get set }        // MUST override before any connect/addServer call
    static var clientMetadataURL: URL? { get set }     // optional — CIMD instead of DCR ([§3d](#3d-cimd-skipping-dynamic-client-registration))
}

final class MCPOAuthRedirectListener {
    static let shared: MCPOAuthRedirectListener
    func handleRedirect(_ url: URL)             // call from your AppDelegate's open-URL handler
}

Advanced: protocol MCPConnection is the transport abstraction MCPServerManager drives internally (connect() async -> Result<MCPServerIdentity, MCPServerError>, listTools(), callTool(name:arguments:allowElicitation:), resource/prompt equivalents, close()). You won't need this unless you're replacing the transport layer itself — everything above already goes through it for you. connect()'s result is what a fresh handshake actually told you:

struct MCPServerIdentity: Sendable, Codable, Equatable {
    var protocolVersion: MCPProtocolVersion
    var serverName: String
    var serverTitle: String              // display name (BaseMetadata `title`, 2025-06-18+); falls back to serverName
    var serverVersion: String?
    var instructions: String?            // free-text usage guidance some servers return
    var capabilities: MCPServerCapabilities
}
// Which optional round-trips are worth firing — absent means "not supported," so a tools-only
// server never gets an unnecessary resources/list call and its method-not-found error.
struct MCPServerCapabilities: Sendable, Codable, Equatable {
    var tools: Bool; var toolsListChanged: Bool
    var resources: Bool; var resourcesListChanged: Bool; var resourcesSubscribe: Bool
    var prompts: Bool; var promptsListChanged: Bool
    var logging: Bool; var completions: Bool
    var hasExperimental: Bool            // server declared something in `experimental`, kept opaque
}
// How the connection talks to the server: initialize + session (through 2025-11-25), or
// 2026-07-28's stateless model (server/discover, per-request _meta, MRTR). Callers of
// MCPServerManager never see the difference.
enum MCPConnectionMode: Sendable, Equatable {
    case handshake(MCPProtocolVersion)
    case stateless(MCPProtocolVersion)
    var negotiatedVersion: MCPProtocolVersion { get }
    var isStateless: Bool { get }
}

A custom MCPConnection also inherits listen(filter:) -> AsyncStream<MCPChangeEvent> with a default that ends at once as .refused. Override it only if your transport supports change notifications. MCPChangeEvent is .acknowledged(filter), .toolsChanged, .promptsChanged, .resourcesChanged, then a final .ended(MCPListenEnd), where MCPListenEnd is .closed, .failed(String), .refused(String) or .unauthorized.

Credential presence, without a Keychain prompt. A host that lets a user swap the entire MCP server list in one shot — a config-profile Load — needs to know, for each server about to be restored, whether reconnecting will need a fresh sign-in. A server that authenticates via reactive/auto-discovered OAuth carries MCPAuthType.none in its persisted entry (only .pat and .oauthManual are declared up front), so the only way to tell is to check whether a token already exists — without decrypting it, which would trigger the Keychain's user-consent prompt even on a build that isn't yet trusted for the item (e.g. a fresh ad-hoc-signed dev build):

enum MCPStoredCredentialKind: String, Sendable, Equatable { case oauth, pat }
enum MCPCredentialProbe {
    // presence-only SecItem query (kSecReturnData false) — never decrypts the secret
    static func storedKind(for server: URL) -> MCPStoredCredentialKind?
    // true when a sign-in would be needed to use `server` — i.e. it declares up-front auth, or a
    // token for it is already stored
    static func requiresAuth(declared authType: MCPAuthType, server: URL) -> Bool
}

Pair this with removeAllKeepingCredentials() + restore(from:) above: clear every in-memory server without touching Keychain tokens, then restore the new list — a server that reappears in it reconnects without a fresh sign-in, and MCPCredentialProbe is what your Save/Load UI uses to tell the user which servers will need one.

MCPTool (Path A for MCP)

Builds a Tool at runtime directly from an MCPToolDescriptor's real JSON Schema — no hand-written Arguments struct. Arguments is GeneratedContent (not a static type), and parameters is computed from the tool's schema rather than derived from Arguments the usual way. The initializer is throwing — a tool whose schema doesn't build should be skipped, not let crash your whole tool list; see §7a (the two tool-calling paths) for the recommended loop shape.

struct MCPTool: Tool {
    let name: String
    let description: String
    let parameters: GenerationSchema   // built from descriptor.rawSchema, not from Arguments
    // Arguments == GeneratedContent

    init(descriptor: MCPToolDescriptor, manager: MCPServerManager) throws
    func call(arguments: GeneratedContent) async throws -> String
}

enum MCPToolAdapterError: Error, CustomStringConvertible {
    case invalidRawSchema           // rawSchema wasn't valid JSON
    case unbuildableSchema(String)  // FoundationModels rejected the resulting GenerationSchema
}

Common JSON Schema shapes (object/properties/required, array/items, string/number/integer/ boolean, string enums) convert cleanly. Anything past that — oneOf/anyOf unions, $ref, const, regex pattern — degrades to a free-form string leaf rather than failing the whole tool, since the remote server is still the real source of argument validation.

MCP Apps (LocalLMLabSDKMCPAppsHost)

Open source, shipped as source like Components, on Core's public API only. Walkthrough: §3f.

// --- declare support, fetch and check a view ----------------------------------
extension MCPClientHandlers { func advertisingMCPApps() -> MCPClientHandlers; static let mcpAppsExtensionKey: String }
struct MCPAppResource: Sendable {           // a validated view: text/html;profile=mcp-app, size-capped
    static let mimeType = "text/html;profile=mcp-app"
    let html: String; let sha256: String     // pin / compare versions by sha256
    init(content: MCPResourceContent, requestedURI: String, listedMeta: MCPValue? = nil) throws
}
@MainActor final class MCPAppWidgetCache {   // views on disk by hash; LRU past maxBytes (64 MB)
    init(directory: URL? = nil, maxBytes: Int = 64 * 1024 * 1024)
    func fetch(resourceURI: String, server: MCPServerID, manager: MCPServerManager, recordID: String? = nil) async throws -> MCPAppResource
    func store(_ content: MCPResourceContent, requestedURI: String) throws -> MCPAppResource
    func resource(sha256: String) -> MCPAppResource?             // nil if missing or tampered
    func bind(recordID: String, sha256: String); func sha256(forRecord recordID: String) -> String?
}

// --- render ---------------------------------------------------------------------
@MainActor final class MCPAppViewController: NSObject, ObservableObject {
    init(resource: MCPAppResource, tools: [MCPToolDescriptor], backend: any MCPAppsBackend,
         configuration: MCPAppsBridgeConfiguration,
         bridgePolicy: any MCPAppsBridgePolicy = MCPAppsDefaultPolicy(),   // tool calls allowed; messages, context, links denied
         sandboxPolicy: MCPAppsSandboxPolicy = .closed,                   // no network unless allowed
         actions: MCPAppsHostActions = MCPAppsHostActions(),
         supportedDisplayModes: [String] = ["inline"],
         audit: @escaping @MainActor (MCPAppsAuditEvent) -> Void = { _ in })
    let webView: WKWebView; let bridge: MCPAppsBridge; let resource: MCPAppResource
    var contentHeight: CGFloat? { get }      // the view's reported height
    func load() async; func close() async    // after close(), release the controller to end its process
}
struct MCPAppView: NSViewRepresentable { init(controller: MCPAppViewController) }

// --- a view's calls go through the session ----------------------------------------
struct MCPAppsSessionBackend: MCPAppsBackend {     // tools/call → session.callMCPTool(…, initiator: .app(instance:))
    init(session: LocalLMLabSession, manager: MCPServerManager, server: MCPServerID, instance: String)
}
extension MCPAppsHostActions {                     // ui/message → a turn; ui/update-model-context → model context
    static func session(_ session: LocalLMLabSession, instance: String, openLink: … = { _ in },
                        onReply: … = { _ in }, requestDisplayMode: … = { _ in false }) -> MCPAppsHostActions
}

// --- many views in one conversation -------------------------------------------------
@MainActor final class MCPAppViewPool: ObservableObject {
    init(limit: Int = 3); var limit: Int; var liveKeys: [String] { get }
    func controller(for key: String, make: () throws -> MCPAppViewController) rethrows -> MCPAppViewController
    func touch(_ key: String); func isLive(_ key: String) -> Bool; func snapshot(for key: String) -> NSImage?
    func release(_ key: String) async; func releaseAll() async
}
enum MCPAppRecreation {
    static func revalidate(_ record: ToolCallRecord, source: any MCPAppWidgetSource, cache: MCPAppWidgetCache,
                           approvedSHA256: Set<String>? = nil) async -> Revalidation?
    static func deliver(_ record: ToolCallRecord, to controller: MCPAppViewController) -> Bool   // stored arguments + result
    struct Revalidation { var decision: MCPAppRecreationDecision; var resource: MCPAppResource?; var widgetChanged: Bool }
}
enum MCPAppRecreationDecision { case render(viewOnly: Bool), needsRefresh(mayRefreshAutomatically: Bool), notApproved, unavailable }
struct MCPServerManagerWidgetSource: MCPAppWidgetSource { init(manager: MCPServerManager, server: MCPServerID) }
// MCPResponseLimits.maxUIResourceBytes (Core) caps a view's HTML (4 MB).

Components (LocalLMLabSDKComponents)

@MainActor
final class MCPServerManagerObservable: ObservableObject {
    let core: MCPServerManager
    @Published private(set) var servers: [MCPServerID: MCPServerState]
    var sortedServers: [MCPServerState] { get }
    init(core: MCPServerManager)
}
// MCPServerError also gets LocalizedError conformance here, for human-readable error text in UI.

struct MCPServerPickerView: View {
    init(manager: MCPServerManagerObservable)
    // Add/list/reconnect/disconnect/remove, all three auth types, per-tool and per-resource
    // enable/disable, prompts listing (read-only), and a "Save As…" text export per server.
    // Per server (2.0): trust + approval controls (setTrust / setToolApproval), a "no tools on" notice,
    // and a "tools changed since trusted" notice with Trust Again.
}

struct MCPOAuthWaitingView: View {
    init(serverName: String, onCancel: @escaping () -> Void)
}

struct MCPResourcesView: View {
    init(manager: MCPServerManagerObservable, onAttach: @escaping (MCPResourceDescriptor, MCPResourceContent) -> Void)
}

struct MCPPromptsView: View {
    init(manager: MCPServerManagerObservable, onUse: @escaping (MCPPromptDescriptor, [MCPPromptMessage]) -> Void)
}

// Model layer (1.0)
struct ModelPickerView: View {
    init(registry: ModelRegistry, selection: Binding<ModelID?>, show27OnlyModels: Bool = true)
    // Lists registry.knownModels with an availability badge each (Ready / Not downloaded /
    // Needs credential / Requires macOS 27). When LocalLMLabSDKInference is linked, also a
    // "Downloaded models" section: installed models + on-disk size, a byte-formatted progress
    // bar + Cancel button per registry.downloads entry (bytesReceived/totalBytes, not just a
    // fraction), and an "Add from Hugging Face" field → registry.startDownload(_:). Binds to
    // the @Observable registry directly — no polling. On macOS 26, show27OnlyModels: true adds
    // disabled "Requires macOS 27" rows for pcc/claude/mlx; false hides them.
}
struct ClaudeAuthField: View {
    init(apiKey: Binding<String>, onCommit: @escaping () -> Void = {})
    // A ready-made secure field for the Claude API key that ClaudeModelProvider(auth: .apiKey(_:)) needs.
}

// Model onboarding, updates and cleanup (1.0.0-RC.1) — Components/README.md has the adapter for MLXModelProvider.
struct ModelOnboardingRequest: Sendable, Equatable, Identifiable {
    init(role: String = "Model", repoID: String, expectedRevision: String? = nil, validates: Bool = true)
}
struct ModelOnboardingSource: Sendable {
    init(provider: any DownloadableModelProvider)     // or init(registry: ModelRegistry)
}
enum ModelOnboardingStepState: Sendable, Equatable {
    case pending, running, done(String), failed(String)
}
struct ModelOnboardingItem: Sendable, Equatable, Identifiable {   // one repo's progress through the flow
    var id: String { request.repoID }
    var request: ModelOnboardingRequest
    var validate: ModelOnboardingStepState = .pending
    var download: ModelOnboardingStepState = .pending
    var pin: ModelOnboardingStepState = .pending
    var downloadFraction: Double?    // 0...1 while downloading, nil otherwise
    var downloadedBytes: Int64 = 0
    var totalBytes: Int64 = 0
    var hasFailed: Bool { get }; var isComplete: Bool { get }     // true once `pin` is .done
}
@MainActor @Observable final class ModelOnboardingModel {
    init(requests: [ModelOnboardingRequest], source: ModelOnboardingSource)
    func start(); func cancel(); func reset()
    var items: [ModelOnboardingItem] { get }; var isFinished: Bool { get }; var hasFailed: Bool { get }
}
struct ModelOnboardingView: View {
    init(model: ModelOnboardingModel, showsDownloadProgress: Bool = true,
         onFinished: (([InstalledModel]) -> Void)? = nil, onDismiss: (() -> Void)? = nil)
}
struct ModelFileChange: Sendable, Equatable, Identifiable {
    enum Kind: Sendable, Equatable { case added, removed, modified }
    var id: String { path }
    init(path: String, kind: Kind, oldSize: Int64? = nil, newSize: Int64? = nil)
}
struct ModelUpdateOffer: Sendable, Equatable {
    var current: String       // the version the model is on now
    var available: String     // the version on offer
    var changes: [ModelFileChange]
    var isUpToDate: Bool { current == available }
    init(current: String, available: String, changes: [ModelFileChange] = [])
}
enum ModelUpdateOwnership: Sendable, Equatable {
    case userChosen             // the user picked this model, so the user decides; the offer is whatever is newest
    case developerOffered       // built into the app — the developer vets versions; the offer is one they vouch for
    case fixed(reason: String)  // can't be updated here; `reason` is shown as-is
}
struct ModelUpdateActions: Sendable {
    init(check: @escaping @Sendable () async throws -> ModelUpdateOffer,
         apply: @escaping @Sendable (_ revision: String, _ progress: @escaping @Sendable (Double) -> Void,
                                     _ beforeSwitch: @escaping @Sendable () async throws -> Void) async throws -> Void)
    // apply has exactly the shape of MLXModelProvider.updatePin(_:to:beforeSwitch:)
}
@MainActor @Observable final class ModelUpdateModel {
    init(actions: ModelUpdateActions, ownership: ModelUpdateOwnership, currentRevision: String? = nil,
         pauseInference: (@Sendable () async throws -> Void)? = nil)
    func checkForUpdate() async; func update() async; func rollBack() async; func revertToShippedVersion() async
}
struct ModelUpdateView: View { init(model: ModelUpdateModel) }
struct ModelVersionRow: Sendable, Equatable, Identifiable {   // one cached version of a model, for the cleanup list
    var id: String { revision }
    var revision: String
    var isCurrent: Bool     // the version in use; cannot be removed
    var isComplete: Bool    // false for a half-downloaded version
    var freesBytes: Int64   // what removing this version would actually free — its own files, not ones it shares
    var sharedBytes: Int64  // bytes shared with other versions, which removing this one does NOT free
    init(revision: String, isCurrent: Bool, isComplete: Bool = true, freesBytes: Int64, sharedBytes: Int64 = 0)
}
@MainActor @Observable final class ModelVersionsModel {
    init(list: @escaping @Sendable () async -> [ModelVersionRow],
         remove: @escaping @Sendable (ModelVersionRow) async throws -> Int64)   // returns bytes freed; must refuse the current version
    func refresh() async; func remove(_ row: ModelVersionRow) async
}
struct ModelVersionsView: View { init(model: ModelVersionsModel) }

// Online providers (1.0.0-beta.3) — the "AI Models" settings panel. Host owns [RemoteProviderDraft]
// (keys → Keychain) and maps a draft → RemoteProviderConfig in the closures. Components does not
// link LocalLMLabSDKRemote; see [§6b](#6b-online-providers--gpt-claude-online-openrouter-locallmlabsdkremote) and examples/model-switch/ProviderGlue.swift.
struct AIModelsSettingsView: View {
    init(registry: ModelRegistry, providers: Binding<[RemoteProviderDraft]>,
         onSave: @escaping (RemoteProviderDraft) -> Void,
         onRemove: @escaping (RemoteProviderDraft) -> Void,
         onTest: ((RemoteProviderDraft) async -> ProviderTestOutcome)? = nil)
    // Built-in families + one ProviderSettingsSection per online provider + an "Add provider" menu.
}
struct ProviderSettingsSection: View {
    init(draft: Binding<RemoteProviderDraft>,
         onSave: @escaping (RemoteProviderDraft) -> Void,
         onRemove: (() -> Void)? = nil,
         onTest: ((RemoteProviderDraft) async -> ProviderTestOutcome)? = nil)
    // One provider: key field, Configured badge, per-model rows, web-search toggle + max-searches
    // stepper, "Test connection" (one result per model). Usable standalone.
}
struct RemoteProviderDraft: Identifiable, Hashable, Sendable {
    var scheme, displayName, baseURL, apiKey: String
    var kind: RemoteProviderKind
    var models: [String]                 // .new(_:) leaves this empty — host prefills
    var webSearchSupported, webSearchEnabled, configured: Bool
    var maxSearches: Int
    var statusText: String?
    static func new(_ kind: RemoteProviderKind) -> RemoteProviderDraft
}
enum RemoteProviderKind: String, CaseIterable {   // openAIChat, openAIResponses, anthropic, openRouter, openAICompatible
    var addMenuLabel: String { get }
}
struct ProviderTestOutcome: Sendable, Equatable {
    struct ModelResult: Identifiable { var modelId: String; var ok: Bool; var detail: String }
    var results: [ModelResult]           // one per configured model, in order
    var message: String?                 // set instead of results when the check couldn't run
    static func unableToRun(_ message: String) -> ProviderTestOutcome
}
↑ Top