From inside a shipping consumer Mac agent

One JSON line is the entire seam. That is how a Mac app absorbs a new Claude model the same minute Anthropic ships it.

Most articles on this topic recap what Anthropic announced. This one walks through what an actual consumer Mac app does in the two seconds after a fresh Claude alias arrives. The file path is acp-bridge/src/index.ts, the function name is emitModelsIfChanged, and the contract on the Swift side is a single case branch named models_available.

emitModelsIfChangedlastEmittedModelsJson@anthropic-ai/claude-agent-sdk 0.2.112ACP protocol v0.29.2Fazm 2.4.0 / 2.4.1 / 2.4.2
M
Matthew Diakonov
12 min read
4.7from Sourced directly from /Users/matthewdi/fazm: acp-bridge/src/index.ts, ACPBridge.swift, ChatProvider.swift, ShortcutSettings.swift, CHANGELOG.json, package-lock.json
Eleven lines of TypeScript carry the entire runtime contract for new Claude models
Pinned @anthropic-ai/claude-agent-sdk 0.2.112 in acp-bridge/package-lock.json
Two parallel warmup sessions land the first frame in roughly two seconds

The short version

When Anthropic ships a Claude model update in April 2026, the news lands first on the API side. Inside Fazm, a consumer Mac app, that update has to cross three boundaries before it shows up on a user's screen: from the Anthropic API into the @anthropic-ai/claude-agent-sdk subprocess, from that subprocess across an interprocess newline-delimited JSON channel into the Swift app, and from Swift onto the SwiftUI picker. The April 2026 release of Fazm 2.4.0 made all three of those crossings dynamic. This page is the runtime trace of that crossing, file by file, line by line, with the dedupe variable, the alias filter, the pinned SDK version, and the parallel warmup that puts the new alias on every user's pill in roughly two seconds.

models_available frameemitModelsIfChangedlastEmittedModelsJsonfilter modelId !== 'default'Promise.all session/newACP protocol v0.29.2@anthropic-ai/claude-agent-sdk 0.2.112ACPBridge.swift line 1131ShortcutSettings.updateModelsCmd+Shift+Space pillApril 20 / 22 / 26 / 27

The frame, on disk, in eleven lines

This is the function that decides whether an Anthropic Claude model update reaches the Swift side of the app. It is eleven lines of TypeScript at /Users/matthewdi/fazm/acp-bridge/src/index.ts lines 1271 to 1281. Three behaviours are baked in: a stderr dump of the raw SDK payload (so a working Mac log file is the source of truth), a filter that drops the literal default alias from the user-visible payload, and a JSON-string dedupe against the most recent emit.

acp-bridge/src/index.ts (lines 1245-1281)

The frame in motion

Anthropic ships an update on the API side, the agent SDK (pinned to 0.2.112) reports the new alias in its session/new response, the bridge forwards a single newline-delimited JSON object, and the Swift app re-renders. Four hops total.

Anthropic Claude model update — the April 2026 runtime path

Anthropic API
claude-agent-sdk
session/new RPC
emitModelsIfChanged
models_available frame
ACPBridge.swift case
ShortcutSettings.updateModels
SwiftUI floating bar pill
2s typical

Emitted models_available: claude-haiku-4-5-20251001=Haiku 4.5, claude-sonnet-4-6=Sonnet 4.6, default=Opus (latest)

Real bridge log line written by acp-bridge/src/index.ts to stderr after each emit

The wire-level contract

The frame travels over the bridge subprocess's stdout to the parent Swift process. The Swift parser is six lines. The handler that pushes the parsed list to the SwiftUI picker is another seven. Three components, one JSON line, two parallel session warmups.

From Anthropic API to a SwiftUI pill

Anthropic APIclaude-agent-sdkacp-bridgeACPBridge.swiftShortcutSettingsmodel alias rolled outsession/new (warmup #1)session/new (warmup #2)result.models.availableModels{ type: "models_available", models }onModelsAvailable(parsed)@MainActor updateModels(_:)

The Swift side of the same JSON line

On the receiving end, the parser is at /Users/matthewdi/fazm/Desktop/Sources/Chat/ACPBridge.swift line 1131. It reads the dictionary array, hands it to deliverMessage, which compactMaps each entry into a typed tuple. A nil-name or nil-modelId row is silently dropped. The handler onModelsAvailable was registered at the chat provider; it dispatches onto the main actor and overwrites ShortcutSettings.shared.availableModels.

Desktop/Sources/Chat/ACPBridge.swift (lines 1131-1213)

Why one JSON string is the entire dedupe

The bridge does not maintain a model registry, a hash, or a structural-equal helper. It maintains a single string named lastEmittedModelsJson and compares the next emit byte-for-byte against it. That is enough because the SDK's output is canonical (modelId, name, optional description, in the same key order every time), the array order is stable across responses, and an Anthropic Claude model update only changes a few characters in the string. When those characters change, the dedupe misses, the new frame fires, and the Swift handler updates the picker. When they don't change, the bridge stays silent and the picker stays put. No timers, no debounce, no background polling.

Why two seconds is roughly the worst case

The frame can only fire after the bridge has issued at least one session/new RPC against the local agent subprocess, because that is the RPC whose response carries availableModels. The bridge runs two warmup sessions in parallel via Promise.all rather than serially, so total time is dominated by the slower of the two. There is also a 2-second retry guard baked in: if the first attempt errors out, the bridge waits and re-issues exactly once. That guard is the source of the worst-case bound.

acp-bridge/src/index.ts (lines 1296-1366)
0lines of TypeScript carry the model frame
0stypical cold-launch to first frame
v0pinned claude-agent-sdk in lock file
0pills on the Cmd+Shift+Space picker

The April 2026 release ladder

Three Fazm releases in eight days. Each one ships a single piece of the seam that absorbs an Anthropic Claude model update. None of them require the user to do anything.

1

April 20, 2026 - Fazm 2.4.0 ships the seam

The CHANGELOG line is verbatim: `Available AI models now populate dynamically from the agent, so newly released Claude models appear without an app update.` The same release records `Upgraded Claude agent protocol to v0.29.2.` Before this build, the picker was a static array baked into the Swift binary. After it, the bridge's models_available JSON line is the source of truth.

2

April 22, 2026 - Anthropic flips the alias and Fazm 2.4.1 ships the partial-list fix

Anthropic ships its model update; the agent SDK begins reporting the highest-Opus tier under a different alias. Fazm 2.4.1 ships the same day with one bullet: `Fixed model label showing "Smart" for Sonnet users when Anthropic reports a partial model list.` The fix lives in shortLabel(for:) at ShortcutSettings.swift lines 222 to 229. The pill label stays correct even on a launch where the SDK only returns two of three tiers.

3

April 26, 2026 - Fazm 2.4.2 ships the legacy migration

The companion ladder lands. CHANGELOG: `Fixed Smart (Opus) model preference not persisting after app update - now correctly maps stored "opus" to the new ACP model ID.` The function is normalizeModelId(_:) at ShortcutSettings.swift lines 170 to 177, seven lines, three substring branches. It rewrites the legacy persisted UserDefaults string on the next launch.

4

April 27, 2026 - all three releases together

By the end of the month, the picker has three guarantees that nothing else in the consumer Mac space ships at once: dynamic model list (2.4.0), partial-list label fallback (2.4.1), and persisted-preference migration (2.4.2). The combined effect is that an Anthropic Claude model update lands the same minute Anthropic ships it, with the user's existing pill label and existing pill choice both intact.

The six anchor facts

Six concrete things on disk that make the seam work. Every one of them is a string you can grep for in the source tree.

emitModelsIfChanged

acp-bridge/src/index.ts lines 1271-1281. Eleven lines of TypeScript. Filters the literal `default` alias, JSON.stringify-dedupes against lastEmittedModelsJson, then writes one newline-delimited frame to stdout. The whole runtime contract for new Claude models lives here.

lastEmittedModelsJson

Single module-scope `let` at line 1249. Holds the most recently emitted JSON string. A canonical-stringify equality check is the entire dedupe.

ACP protocol v0.29.2

Bumped in Fazm 2.4.0 on April 20, 2026. Recorded verbatim in CHANGELOG.json. The protocol bump is what made the agent SDK report availableModels on session/new in the first place.

@anthropic-ai/claude-agent-sdk 0.2.112

Pinned in acp-bridge/package-lock.json line 30. Resolved tarball URL recorded at line 48. This is the version the model frame is sourced from.

case "models_available":

ACPBridge.swift line 1131. The Swift parser branch. Reads the `models` field, hands a (modelId, name, description) tuple list to onModelsAvailable.

Promise.all warmup

acp-bridge/src/index.ts line 1320. Two warmup sessions issued in parallel. The first models_available frame typically lands within roughly two seconds of cold launch.

What the dedupe actually looks like in stderr

A real launch in dev mode writes these lines, in this order, to /tmp/fazm-dev.log. The first emit happens after the first session/new response; subsequent session warmups land identical model arrays and the dedupe correctly stays silent.

/tmp/fazm-dev.log (excerpt)

What every other piece on this topic gets right, and what it skips

Nothing else missing here is an opinion; it is just where the documentation tends to stop. Most pieces stop at the model card. This row is for the people who already read the model card.

FeatureMost explainersFazm runtime
How does the consumer Mac app learn that an Anthropic Claude model update happened?Hardcoded list of model IDs in the binary; user gets the new model only after an App Store release per updatemodels_available frame emitted by the local agent bridge subprocess on session/new, parsed by ACPBridge.swift line 1131
How does the bridge avoid spamming the Swift side with duplicate frames?Either fires on every session, forcing the UI layer to debounce, or fires only once per process and misses mid-session updatesSingle string variable lastEmittedModelsJson at line 1249; canonical JSON.stringify equality check before each emit
What happens if the SDK rate-limits and only two tiers come back?Picker label falls through to a hardcoded default (e.g. always 'Smart'), which is the wrong word for a Sonnet usershortLabel(for:) falls through availableModels, defaultModels, normalized alias; selectedModelShortLabel returns 'Fast' (line 232)
How long after launch does the new alias appear on the pill?Whenever the user manually opens settings and scrolls to the model sectionAbout two seconds; preWarmSession runs Promise.all over both warmup sessions in parallel, frame fires on the first session/new response
What ships when Anthropic renames a model alias?App Store push with hardcoded array updated; days of review, no users on the new model in the meantimeEleven lines of TypeScript handle the live frame; CHANGELOG entry documents the seam, no binary change required for the rename itself

Want a walk-through of the same seam in your own Mac app?

Book a 30-minute call. We can pull up the bridge, the JSON frame, and the Swift handler on a screenshare and trace your version of the same path.

Frequently asked questions

What is the actual JSON frame Fazm reads when an Anthropic Claude model update lands in April 2026?

It is a newline-delimited JSON object emitted from the local Node subprocess named acp-bridge over its stdout, with the exact shape `{ type: "models_available", models: [...] }`. The producer is the function emitModelsIfChanged at /Users/matthewdi/fazm/acp-bridge/src/index.ts lines 1271 to 1281. The consumer is the case branch `case "models_available":` at /Users/matthewdi/fazm/Desktop/Sources/Chat/ACPBridge.swift line 1131, which parses the array into tuples of (modelId, name, description). The frame is the only place a fresh Anthropic model can enter Fazm's UI without a binary update, and a real launch line in /tmp/fazm-dev.log shows the bridge writing it within roughly two seconds of process start. The full payload also gets logged to stderr above the emit as `Raw models from ACP SDK: [...]`, so you can read it back from a working Mac directly.

Why does the bridge dedupe with a single string variable instead of comparing arrays?

Look at /Users/matthewdi/fazm/acp-bridge/src/index.ts line 1249. It declares one module-scope let, `let lastEmittedModelsJson = ""`. Inside emitModelsIfChanged the function calls JSON.stringify on the filtered list, compares the resulting string to lastEmittedModelsJson, and short-circuits when they match. The shape comparison is a string equality, not a deep equal. Two reasons the design lands here. First, the SDK is allowed to re-report the same list on every session/new RPC, and the bridge issues two of those at warmup (Promise.all over `[DEFAULT_MODEL, SONNET_MODEL]` at line 1308), so without dedupe the Swift side would receive a duplicate models_available frame on every launch. Second, the JSON is short enough that string-comparison cost is negligible, while a structural deep-equal would have to handle key-ordering, optional description fields, and array order. A canonical JSON string handles all three.

Why does the bridge filter out the model whose modelId is literally "default"?

The filter is at /Users/matthewdi/fazm/acp-bridge/src/index.ts line 1274: `const filtered = availableModels.filter(m => m.modelId !== "default")`. The string `default` is the SDK's stable handle for the highest-quality Opus tier, and the SDK reports it alongside the versioned Opus alias. If the filter were absent, the picker would render two pills both labelled Smart, one with id default and one with id opus-something, and a user clicking either would route to the same model. The filter applies only to the user-visible payload; the bridge still uses `default` internally as a session warmup target. The April 2026 update changed which versioned alias the SDK reports next to default, but did not change the filter, so the picker stays at three pills (Scary, Fast, Smart) without a release.

What pinned version of @anthropic-ai/claude-agent-sdk does the bridge ship with, and where is that recorded?

Exactly 0.2.112. It is recorded in /Users/matthewdi/fazm/acp-bridge/package-lock.json at line 30 (`@anthropic-ai/claude-agent-sdk: 0.2.112`) and at line 48 (`resolved: https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.112.tgz`). The same lock file pins @anthropic-ai/sdk to 0.81.0 at line 75. The 2.4.0 CHANGELOG.json entry on April 20, 2026 also records the agent protocol bump verbatim: `Upgraded Claude agent protocol to v0.29.2`. So when an Anthropic Claude model update arrives in April 2026, the bridge does not need an npm install to see the new alias, because the SDK shipped with 2.4.0 already negotiates the live model list at session/new and sends back whatever Anthropic is currently routing the alias to.

How fast does the new model alias actually appear on screen after a cold app launch?

The bridge calls preWarmSession at /Users/matthewdi/fazm/acp-bridge/src/index.ts line 1296. The function fans out a Promise.all over the warmup configs (line 1320), each of which issues a session/new RPC against the local agent subprocess. The SDK's response includes `models.availableModels`, which is the field the bridge then forwards through emitModelsIfChanged. On a typical Mac the first session/new resolves in roughly one to two seconds; both warmup sessions then complete in parallel rather than serially, so the Swift handler ChatProvider.swift line 1016 is wired up before the user has finished moving the cursor. There is also a 2-second retry guard at line 1357 that re-issues session/new once if the first attempt errors. Pinning down a single number is misleading because everything depends on bridge spawn time and OAuth state, but the typical observed window between cold launch and the first models_available frame is about two seconds.

What happens if Anthropic ships a model update but rate-limits the response, so only two of three tiers come back?

Two things happen. First, the bridge dedupes anyway: lastEmittedModelsJson holds whatever was last emitted, and a partial list is still a different list, so the frame fires. Second, the floating bar's pill label survives because of a fallback added in Fazm 2.4.1 on April 22, 2026. The shortLabel(for:) function at /Users/matthewdi/fazm/Desktop/Sources/FloatingControlBar/ShortcutSettings.swift lines 222 to 229 falls through `availableModels` first, then the static `defaultModels` array at lines 151 to 155, then the normalized alias against defaultModels. If a Sonnet user lands on a launch where the SDK only reported Haiku and Opus, shortLabel still returns `Fast` from defaultModels rather than collapsing to the wrong pill. The 2.4.1 changelog entry names this bug verbatim: `Fixed model label showing "Smart" for Sonnet users when Anthropic reports a partial model list.`

Where does the model frame route after Swift parses it?

Three hops. ACPBridge.swift line 1131 enters the `models_available` case, line 1132 reads the `models` field as an array of dictionaries, and line 1133 returns `.modelsAvailable(models: models)` to the frame loop. The frame loop calls deliverMessage, which at line 1202 enters the modelsAvailable branch, parses each dict into a (modelId, name, description) tuple at line 1204, and fires `onModelsAvailable?(parsed)` at line 1211. ChatProvider.swift line 1016 had previously registered that handler with `await acpBridge.setModelsAvailableHandler { models in Task { @MainActor in ShortcutSettings.shared.updateModels(models) } }`, so the parsed list lands inside updateModels(_:) on the main actor. From there it drives the @Published var availableModels at line 167, which the SwiftUI picker observes. One JSON line in, three SwiftUI pills out.

Does the bridge fall back to anything if the SDK never reports a model list at all?

Yes. /Users/matthewdi/fazm/acp-bridge/src/index.ts line 1308 contains the legacy fallback path: when the warmup is invoked without an explicit sessionConfigs argument, the bridge defaults to `[DEFAULT_MODEL, SONNET_MODEL]`, both of which are the literal string `claude-sonnet-4-6`. The Swift side has its own fallback in /Users/matthewdi/fazm/Desktop/Sources/FloatingControlBar/ShortcutSettings.swift lines 151 to 155: a static `defaultModels` array of three ModelOption rows for Haiku, Sonnet, and Opus. So a brand-new install on a network with no agent SDK reachable still renders all three pills with sensible labels, even though no models_available frame ever fires. When the SDK does respond on the next launch, the dynamic list overwrites the static one in updateModels at line 197.

What does it look like when an Anthropic Claude model update is rolling out and only some users have it yet?

Two consecutive launches on the same Mac can produce different model frames, and the dedupe string handles that. Suppose launch one happens before Anthropic has flipped the routing, so the SDK reports the old versioned aliases for Haiku, Sonnet, and Opus. The bridge emits the frame, lastEmittedModelsJson holds the JSON of those three IDs. A few minutes later launch two happens after the flip, the SDK now reports the new alias for the Opus tier alongside the same two lower-tier IDs. The JSON differs by a few characters, lastEmittedModelsJson updates, and the new frame is emitted. ShortcutSettings.updateModels at line 197 sees that newModels does not equal availableModels, overwrites availableModels, and logs `ShortcutSettings: updated availableModels to [...]`. Nothing else moves: no notification, no settings dialog, no app update. Just the next click on the Smart pill routes through the new alias.

Could a future Anthropic Claude model update break this design, and what would the warning sign look like?

The seam is robust to renames inside the existing tiers (Haiku, Sonnet, Opus) because the family map at /Users/matthewdi/fazm/Desktop/Sources/FloatingControlBar/ShortcutSettings.swift lines 159 to 164 is substring-matched, not equality-matched. A new Opus alias whose modelId contains the literal `opus` (or that gets routed under the stable `default` handle) is picked up automatically. The seam is fragile only to two cases. First, if a future model update reports a brand-new tier whose modelId does not contain any of `haiku`, `sonnet`, `opus`, or `default`, the picker falls into the `unknown model family: use the API name directly` branch at lines 189 to 191 and renders the raw API name on the pill, which would be the warning sign. Second, if Anthropic stops reporting models at all on session/new and moves the contract to a different RPC, the bridge would fall back to its static defaults until the SDK update lands. Neither has happened so far in April 2026.