Skip to content

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

Open
PaulBlackSwan wants to merge 4 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 4 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 turned on via the hidden LocalAPIEnabled user default and it 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 — accessor reusing the existing "LocalAPIEnabled" key (no change to how the server reads config).
  • LocalAPIServer.refresh() — start/stop the listener live when the setting changes (no app restart).
  • A Local API (on-device agents) toggle in Settings, off by default.
  • README section + docs/local-api.md (endpoints, request/response, security, limits, examples).

Type of Change

  • New feature (non-breaking) — surfaces an existing capability
  • Documentation
  • Bug fix
  • Breaking change

Related Issue or Discussion

None — opened directly. Motivation: reusing FluidVoice's on-device transcription from a local AI agent rather than running a second copy of the model. Happy to convert to a discussion first if you prefer.

Testing

  • xcodebuild -project Fluid.xcodeproj -scheme Fluid -configuration Debug buildBUILD SUCCEEDED (before and after the review fixes).
  • Toggle on → server listening on 127.0.0.1:47733; GET /v1/health200.
  • POST /v1/transcribe {"path": ".../clip.ogg"} → correct transcript, reusing the already-warm model (RSS unchanged — no second model process).
  • Toggle off → listener stops; toggle on → restarts cleanly (exercised the live refresh() path, including rapid off→on).
  • Non-loopback peer → connection refused.

Screenshots / Video

Adds a single Settings row built with the existing optionToggleRow component — visually identical to the neighboring "Copy to Clipboard" row, in the same section.

(Settings → the row reads "Local API (on-device agents)".)

Review feedback addressed (Codex / Greptile)

  • Listener-identity race (P1): stateUpdateHandler now ignores callbacks from a superseded listener (guards self.listener === listener and captures the listener weakly), so a rapid off→on toggle can no longer orphan or cancel the newly-installed listener.
  • Privacy wording (P2): clarified that only the HTTP listener and /v1/transcribe are on-device; /v1/postprocess runs the configured post-processing provider, which may be remote. Fixed the toggle text, README and docs.
  • Binding wording (P2): the listener rejects non-loopback peers at accept time — documented that accurately instead of claiming it "binds 127.0.0.1" (left the existing listener setup unchanged to avoid IPv4/IPv6-loopback regressions; happy to switch to an explicit loopback bind if you'd rather).
  • Limits (P2): documented the 300 s (5 min) audio-duration cap alongside the 25 MB request-body cap, and noted the two are independent.

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
  • Screenshots / Video

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

This PR surfaces the existing Local API for local integrations.

  • Adds a persisted Settings toggle that starts and stops the listener immediately.
  • Guards listener state callbacks against superseded listener instances.
  • Documents Local API endpoints, locality constraints, limits, and usage examples.

Confidence Score: 4/5

The PR appears safe to merge, with a non-blocking correction needed in the documented duration behavior.

The listener race has been fixed, while the remaining issue is that inline audio exceeding 300 seconds is silently truncated despite documentation promising rejection.

Files Needing Attention: docs/local-api.md and README.md

Reviews (5): Last reviewed commit: "fix(local-api): shorten the toggle footn..." | 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
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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs PR template Pull request is missing required template content. needs screenshots Pull request needs screenshot or video evidence.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant