Skip to content

Surface + document the loopback Local API for on-device agents (Settings toggle + docs) - #715

Open
PaulBlackSwan wants to merge 5 commits into
altic-dev:mainfrom
PaulBlackSwan:feat/local-api-toggle-and-docs
Open

Surface + document the loopback Local API for on-device agents (Settings toggle + docs)#715
PaulBlackSwan wants to merge 5 commits into
altic-dev:mainfrom
PaulBlackSwan:feat/local-api-toggle-and-docs

Conversation

@PaulBlackSwan

@PaulBlackSwan PaulBlackSwan commented Jul 26, 2026

Copy link
Copy Markdown

Description

FluidVoice already ships a loopback-only Local API (LocalAPIServer: POST /v1/transcribe, POST /v1/postprocess, GET /v1/health, plus history/dictionary), but it can only be enabled through the hidden LocalAPIEnabled default and is undocumented. This PR surfaces and documents it so other apps and AI agents running on the same Mac can reuse FluidVoice's transcription instead of bundling and loading their own ASR model.

  • SettingsStore.localAPIEnabled — reuses the existing "LocalAPIEnabled" key (no change to how the server reads config).
  • LocalAPIServer.refresh() — starts/stops the listener live when the setting changes (no app restart).
  • A Local API (on-device agents) toggle in the always-visible App Settings section, off by default.
  • README section + docs/local-api.md (endpoints, request/response, security, limits, examples).

Type of Change

  • 🐞 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 🧹 Chore
  • 📝 Documentation update

Related Issue or Discussion

Related to #529 (Companion CLI for Local API — adoption discussion) and #414 (Extend the LocalAPI to read/set the speech model…). This PR surfaces the existing Local API via a Settings toggle and documents it (endpoints, security, limits), which directly supports the external/agent adoption those threads discuss. No dedicated issue was opened for the toggle+docs itself — happy to file one if you'd prefer.

Testing

  • Tested on Intel Mac
  • Tested on Apple Silicon Mac
  • Tested on macOS version: 26 (Apple Silicon)
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml Sources
  • Ran formatter locally: swiftformat --config .swiftformat Sources
  • Ran tests locally:

xcodebuild -scheme Fluid -configuration Debug buildBUILD SUCCEEDED. Manual checks: toggle on → GET /v1/health 200 on 127.0.0.1:47733; POST /v1/transcribe {"path": …} returns the transcript reusing the already-warm model; toggle off → listener stops; rapid off→on handled cleanly; non-loopback peer refused.

Screenshots / Video

Local API (on-device agents) toggle in App Settings
  • No UI/visual changes; screenshots/video are not applicable.

Notes

Addressed the automated review feedback:

  • Listener-identity race (Codex P2 / Greptile P1): stateUpdateHandler now guards self.listener === listener (weak capture) so a rapid off→on toggle can't orphan or cancel the freshly-installed listener; and connections accepted just before stop() are now dropped instead of handled after disable.
  • Toggle placement: moved out of the accessibility-gated Shortcuts/Options block into the always-visible App Settings section — the Local API is unrelated to global-hotkey/Accessibility permissions, so it shouldn't be hidden behind them.
  • Wording & limits: only the HTTP listener and /v1/transcribe are on-device; /v1/postprocess follows the configured provider (may be remote). Documented the 300 s audio cap (file-path input rejected; inline audioBase64 truncated) and that enabling via defaults needs a relaunch, whereas the Settings toggle applies live.

The loopback-only Local API (POST /v1/transcribe, /v1/postprocess) already
exists but was enable-only via a hidden "LocalAPIEnabled" default and was
undocumented. This surfaces and documents it:

- SettingsStore.localAPIEnabled (reuses the existing "LocalAPIEnabled" key)
- LocalAPIServer.refresh() to start/stop the server live when toggled
- a "Local API (on-device agents)" toggle in Settings (loopback-only, off by default)
- README section + docs/local-api.md documenting endpoints & security

Lets other apps/agents on the same Mac reuse FluidVoice's transcription
instead of loading their own ASR model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added needs PR template Pull request is missing required template content. needs screenshots Pull request needs screenshot or video evidence. labels Jul 26, 2026
@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

The PR Policy check is blocking this PR because required template information is missing.

Please update the PR description with:

  • Related Issue or Discussion

Visual files detected:

  • Sources/Fluid/Persistence/SettingsStore.swift
  • Sources/Fluid/UI/SettingsView.swift

Screenshots or video are required for UI, UX, settings, onboarding, overlay, menu bar, or visual behavior changes. If this PR has no visual changes, check the no-visual-change box in the template.

If this remains incomplete for 48 hours after opening, the PR may be closed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66dbd621be

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread Sources/Fluid/UI/SettingsView.swift Outdated

self.optionToggleRow(
title: "Local API (on-device agents)",
description: "Expose a loopback-only HTTP API on 127.0.0.1 (default port 47733) so local agents/apps on this Mac can reuse FluidVoice's transcription (POST /v1/transcribe). Off by default; never leaves this machine.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Qualify the local-only privacy promise

When the configured post-processing provider is OpenAI, Groq, Anthropic, or another remote provider, a caller of the enabled /v1/postprocess endpoint has its text sent externally: InferenceAPIController.postprocess delegates to DictationPostProcessingService.process, which calls LLMClient with the selected provider URL. Therefore the unqualified “never leaves this machine” promise is false for one of the documented endpoints and may cause users to expose agent-supplied text under an incorrect privacy assumption; clarify that only the HTTP listener is local and that post-processing follows the configured provider.

Useful? React with 👍 / 👎.

Comment thread README.md Outdated

FluidVoice can expose a **loopback-only** HTTP API so other apps and AI agents running **on the same Mac** can reuse its transcription and post-processing — instead of bundling and loading their own ASR model. One model stays warm in FluidVoice; agents get FluidVoice-quality transcription with no extra memory footprint.

**Enable it:** `Settings → Local API (on-device agents)`. It is **off by default** and only ever binds to `127.0.0.1` (non-loopback connections are rejected at the listener).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind the listener to loopback before claiming it does

The server currently creates NWListener(using:on:) with only a port and never sets a loopback requiredLocalEndpoint, so it listens on the wildcard local address; the newConnectionHandler merely rejects non-loopback peers after their connections have reached the listener. Consequently the documented guarantee that it “only ever binds to 127.0.0.1” is inaccurate and the port remains externally visible/reachable at the transport layer. Either bind the listener explicitly to loopback or describe the accept-time filtering accurately.

Useful? React with 👍 / 👎.

Comment on lines +85 to +90
func refresh() {
if LocalAPI.Configuration.current.enabled {
self.start()
} else {
self.stop()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore callbacks from superseded listeners

If the toggle is switched off and then back on before the cancelled listener's asynchronous state callback reaches the main actor, start() installs a new listener but the old listener's .cancelled callback subsequently executes handleState and clears self.listener. The restarted listener is then orphaned, so a later off toggle cannot cancel it and another on toggle may attempt to open a duplicate port. Associate state callbacks with their originating listener and only clear the property when they still match.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown

Greptile Summary

Surfaces the existing loopback Local API and documents its behavior.

  • Adds a persisted Settings toggle that starts and stops the listener immediately.
  • Protects listener state from callbacks belonging to superseded listener instances.
  • Documents endpoints, locality constraints, provider behavior, and independent request-size and audio-duration limits.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the listener lifecycle race is guarded by listener identity, and the duration-limit documentation matches the current file-path rejection and inline-audio truncation behavior.

Reviews (7): Last reviewed commit: "fix(local-api): drop connections queued ..." | Re-trigger Greptile

Comment thread Sources/Fluid/Services/LocalAPI/LocalAPIServer.swift
Comment thread docs/local-api.md Outdated
…o-cap docs)

- LocalAPIServer: ignore state callbacks from a superseded listener (guard
  self.listener === listener, weak capture) so a rapid off->on toggle can no
  longer orphan/cancel the newly-installed listener (Codex P2 / Greptile P1).
- Qualify the privacy claim: only the HTTP listener and /v1/transcribe are
  on-device; /v1/postprocess follows the configured provider (may be remote)
  (toggle text, README, docs).
- Describe the accept-time loopback filtering accurately instead of claiming
  it binds 127.0.0.1.
- Document the 300s (5 min) audio-duration cap alongside the 25 MB body cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@PaulBlackSwan
PaulBlackSwan force-pushed the feat/local-api-toggle-and-docs branch from 7d418cb to 0e68a5d Compare July 26, 2026 20:00
@PaulBlackSwan

Copy link
Copy Markdown
Author

Thanks for the thorough review — all four points addressed in 0e68a5d:

  • Listener-identity race (P1): stateUpdateHandler now guards self.listener === listener (weak capture), so a rapid off→on toggle can't orphan or cancel the freshly-installed listener.
  • Privacy wording: clarified that only the HTTP listener and /v1/transcribe are on-device; /v1/postprocess follows the configured provider (may be remote) — toggle text, README and docs.
  • Binding wording: documented the accept-time loopback filtering accurately rather than claiming it binds 127.0.0.1 (left the listener setup unchanged to avoid IPv4/IPv6-loopback regressions — glad to switch to an explicit loopback bind if you prefer).
  • Docs: added the 300 s (5 min) audio-duration cap next to the 25 MB body cap, noting they're independent.

Rebuilt clean (xcodebuild -scheme Fluid → BUILD SUCCEEDED). Also filled in the PR template; a screenshot of the Settings toggle is coming.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0e68a5d532

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +93 to +94
} else {
self.stop()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel connections queued before disabling the API

When a connection has passed newConnectionHandler but its Task { @MainActor ... } has not executed yet, this call to stop() cannot see it in activeConnections. The queued task then creates and starts the handler without checking the enabled setting or its originating listener, allowing that connection to continue serving requests after the user switches the API off. Associate connection callbacks with the listener generation and cancel them when that listener is no longer current.

Useful? React with 👍 / 👎.

…ed section

The toggle sat next to 'Copy to Clipboard', which lives inside the
`if accessibilityEnabled` block, so it was hidden until the user granted
Accessibility — unrelated to a loopback HTTP API. Moved it to the always-visible
'App Settings' section, and split the long description into description + footnote
so it no longer truncates on one line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@PaulBlackSwan

Copy link
Copy Markdown
Author

Follow-up in d8ef83f: moved the toggle out of the accessibility-gated Shortcuts/Options block into the always-visible App Settings section. It was next to "Copy to Clipboard", which lives inside if accessibilityEnabled { … }, so the Local API toggle was hidden until the user granted Accessibility — unrelated to a loopback HTTP API. Also split the long description into description + footnote so it no longer truncates on one line. Rebuilt clean.

settingsToggleRow renders description and footnote on single truncating lines;
the previous footnote overflowed. Trimmed it to the essentials — the full
contract (non-loopback filtering, limits, post-processing locality) lives in
README + docs/local-api.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@PaulBlackSwan

Copy link
Copy Markdown
Author

Adding a screenshot of the Toggle

image

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e88f57419b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread docs/local-api.md
Comment on lines +12 to +15
You can also toggle it via `defaults`:

```bash
defaults write com.FluidApp.app LocalAPIEnabled -bool true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge State that defaults changes require a relaunch

When FluidVoice is already running, this command only changes the preference: LocalAPIServer.refresh() is invoked exclusively by the Settings toggle, and the lifecycle calls start() only at launch. Consequently, enabling via defaults does not start the listener, and disabling via defaults leaves the API serving until the app is relaunched. Either observe these preference changes or tell command-line users to restart FluidVoice.

Useful? React with 👍 / 👎.

Comment thread docs/local-api.md Outdated
Comment on lines +25 to +27
- Limits: request body **25 MB**; transcription audio **300 s (5 min)** per request (longer audio is
rejected). These caps are independent — compressed audio can satisfy the byte limit yet still hit the
duration limit.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject overlong inline audio before promising a hard limit

For the documented audioBase64 input (and raw audio bodies), decodeAudioSamples reaches LocalAPIAudioDecoder.samples(from:), which reads min(file.length, maxFrames) and silently transcribes only the first five minutes. Only the path-based flow calls validateDurationWithinLimit and rejects overlong files. Clients relying on this statement can therefore receive a successful but incomplete transcript; either validate inline inputs too or document that they are truncated.

Useful? React with 👍 / 👎.

- LocalAPIServer: the deferred @mainactor task now also checks the server is
  still running (listener != nil) before creating a handler, so a connection
  accepted just before stop()/toggle-off is dropped instead of handled.
- docs: note that enabling via `defaults` needs an app relaunch (the Settings
  toggle applies live via refresh()); clarify the 300s audio cap rejects
  file-path input but truncates inline audioBase64 input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@PaulBlackSwan
PaulBlackSwan force-pushed the feat/local-api-toggle-and-docs branch from 34c108d to cec22e7 Compare July 27, 2026 20:36
@PaulBlackSwan

Copy link
Copy Markdown
Author

Latest round (cec22e7):

  • Superseded-listener race (re-flagged P1/P2): this was fixed in 0e68a5dstateUpdateHandler guards self.listener === listener (weak capture) before touching state, so a stale .cancelled/.failed callback from an old listener can't clear or cancel the new one. Verified by inspection against the toggle off→on→off sequence.
  • Connections queued before disable (Codex P2): the deferred @MainActor task now also checks listener != nil before creating a handler, so a connection accepted just before stop() is dropped instead of handled after the API is off.
  • Docs: enabling via defaults requires an app relaunch (the Settings toggle applies live via refresh()); clarified that the 300 s audio cap rejects file-path input but truncates inline audioBase64 input.

Also filled the PR template + added the toggle screenshot to the description.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cec22e7eb0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +282 to +284
title: "Local API (on-device agents)",
description: "Loopback-only HTTP API for on-device agents to reuse transcription (POST /v1/transcribe).",
footnote: "127.0.0.1:47733, off by default. /v1/postprocess may use a remote AI provider.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Authenticate callers before exposing stored dictations

When this toggle is enabled on a multi-user Mac, loopback does not identify the current login user, so any process in another local user session can call the unauthenticated routes. In particular, LocalAPIRouter exposes GET /v1/history, and HistoryAPIController returns raw and processed dictation text plus app and window metadata; the dictionary routes are also writable. Surfacing the API as a transcription toggle without authentication therefore exposes substantially more private and mutable state than the user is told they are enabling.

Useful? React with 👍 / 👎.

self.settingsToggleRow(
title: "Local API (on-device agents)",
description: "Loopback-only HTTP API for on-device agents to reuse transcription (POST /v1/transcribe).",
footnote: "127.0.0.1:47733, off by default. /v1/postprocess may use a remote AI provider.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Show the configured API port

When a user follows the newly documented LocalAPIPort configuration and chooses anything other than 47733, this footnote still presents 127.0.0.1:47733 as the active endpoint even though LocalAPIServer.start() reads and listens on the configured port. Agents configured from the Settings value will consequently connect to the wrong endpoint; label this as the default or render the effective port.

Useful? React with 👍 / 👎.

@github-actions github-actions Bot removed needs PR template Pull request is missing required template content. needs screenshots Pull request needs screenshot or video evidence. labels Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant