feat(mobile): environment-backed voice transcription - #9028

Open
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription
Open

feat(mobile): environment-backed voice transcription#9028
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription

Conversation

@ahalekelly

@ahalekellyahalekelly commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Generated with Claude Code

Problem

Mobile voice input currently requires Apple's on-device transcription, so it only exists on iOS 26+ iPhones — Android and older iOS have no mic button at all (#8718). docs/internals/voice-input.md (added in #8614) already specifies how environment-provided transcription should work, but it was unimplemented.

Change

Implements that spec as written:

  • Environment owns the credential. An OpenAI transcription API key is entered in web Settings → Voice input, persisted in the server's ServerSecretStore with its own explicit redaction — clients only ever see valueRedacted: true. Model defaults to gpt-transcribe, overridable in settings.
  • Capability-gated catalog. A transcription capability on ExecutionEnvironmentCapabilities, plus a derived transcriptionServices list (ids and labels only) on ServerConfig and the settingsUpdated payload. Older servers expose no remote choices; adding/removing the key updates availability live.
  • One-shot signed upload, nothing persisted. The client mints a short-lived signed URL over an authenticated RPC (mirroring the attachment-upload token pattern), POSTs the recording, and gets the transcript back in the same HTTP response. The server buffers in memory (25 MB cap, exact Content-Length enforcement) and forwards to OpenAI; audio is never written to disk, and aborts propagate to the upstream request.
  • Shared environment transcriber in packages/client-runtime implementing the existing VoiceTranscriber contract, with an injected platform transport (expo-file-system upload on mobile, matching the attachment transport split). Environment, service, and locale are captured when a recording starts; a disconnected environment reports unavailable rather than silently falling back.
  • Per-environment source picker on mobile (Settings → Environments): "On this device" vs the environment's service, stored per stable environmentId. Defaults to local when available, otherwise the environment service.
  • Android and iOS < 26 gain voice input automatically wherever an environment advertises transcription — the existing composer mic UI just becomes available.

Web/desktop voice capture remains out of scope, per the spec's boundaries; web only gains the settings section.

Relationship to #5213: that branch covers web/desktop BYOK dictation and has no apps/mobile code; this PR covers the mobile surface against the newer internals spec. Fixes#8718.

Verification

  • End-to-end on an iOS 26.5 simulator: spoken audio → recording → signed upload to the environment → OpenAI → transcript inserted in the composer ("T3 code voice transcription simulator verification."). Silence correctly surfaces the existing "No speech was detected." retry state.
  • Focused tests: server settings round-trip/redaction and transcription route (48), client-runtime environment transcriber and controller (68); targeted typecheck and lint on the five touched packages.

Screenshots

Mobile: per-environment pickerComposer micRecordingTranscript inserted

Web settings (environment key entry):

Implemented by GPT-5.6 Sol (Pi) with orchestration and code review by Claude Fable 5 (Claude Code).


Note

Medium Risk
Touches authenticated upload/transcription HTTP, secret persistence, and OpenAI forwarding with in-memory audio handling; mobile behavior changes when environments advertise transcription.

Overview
Adds environment-backed voice transcription so mobile can transcribe recordings via the connected server (OpenAI) when on-device speech is unavailable, not only on iOS 26+.

Server & contracts: New transcription capability, transcription.createUrl RPC, and a signed one-shot POST route that validates token/size, buffers audio in memory (no disk), and returns { text } from OpenAI. OpenAI API key and model live in server settings, stored in the secret store and redacted to clients; transcriptionServices is advertised on config and settingsUpdated events.

Shared client:createEnvironmentVoiceTranscriber mints URLs, uploads via injectable transport, and honors AbortSignal through atom commands. Server state exposes per-environment transcription service lists gated by capability.

Mobile: Composers pass environmentId into useVoiceInputController, which resolves local vs remote from persisted voiceTranscriptionSources. Settings → Environments adds a per-environment Voice transcription picker ("On this device" vs OpenAI). Mic availability becomes local or any advertised environment service.

Web: General settings gains Voice input (API key + model). Docs updated for mobile/environment flow.

Reviewed by Cursor Bugbot for commit bc83013. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add environment-backed voice transcription with OpenAI integration

  • Adds a signed-URL upload flow: clients call the new transcriptionCreateUrl WS RPC to get an expiring URL, upload audio, and the server forwards it to OpenAI, returning transcribed text. Token validation enforces exact Content-Length and rejects expired or malformed tokens.
  • Server settings now persist the OpenAI transcription API key in the secret store under TRANSCRIPTION_OPENAI_API_KEY_SECRET_NAME; the key is always redacted (value: "", valueRedacted: true) in settings responses. The web General settings panel exposes inputs for the API key and transcription model.
  • Mobile voice input controller accepts environmentId and selects between on-device transcription and environment-provided services based on user preferences and advertised transcriptionServices. A new per-environment row in the environments settings screen lets users pick the transcription source.
  • Client-runtime atom commands and queries now accept an AbortSignal via AtomCommandOptions, enabling cancellation-aware transcription flows in the environment transcriber.
  • Risk: the transcriptionCreateUrl RPC requires AuthOrchestrationOperateScope; callers without it will be rejected. The transcription POST endpoint at TRANSCRIPTION_ROUTE_PREFIX validates tokens server-side — any mismatch in signing key, expiry, or Content-Length returns an error status, so out-of-tree clients must follow the exact upload contract.
📊 Macroscope summarized bc83013. 29 files reviewed, 6 issues evaluated, 1 issue filtered, 4 comments posted

🗂️ Filtered Issues

apps/server/src/http.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 401: The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL. [ Cross-file consolidated ]

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Sep 1, 2026
};
});

export const validateTranscriptionToken = Effect.fn("Transcription.validateToken")(function* (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Hightranscription/Transcription.ts:75

The same valid transcription URL can be POSTed repeatedly until expiresAt, causing each request to invoke OpenAI and potentially incur another charge. validateTranscriptionToken only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.

Also found in 1 other location(s)

apps/server/src/http.ts:401

The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/transcription/Transcription.ts around line 75:
The same valid transcription URL can be POSTed repeatedly until `expiresAt`, causing each request to invoke OpenAI and potentially incur another charge. `validateTranscriptionToken` only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.
Also found in 1 other location(s):
- apps/server/src/http.ts:401 -- The route validates only the stateless signature and expiry at `validateTranscriptionToken`; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

): Promise<AtomCommandResult<A, E>> {
const result = await settleAtomCommandResult(() => command.run(registry, input));
const result = await settleAtomCommandResult(() => command.run(registry, input, options.signal));
reportAtomCommandResult(result, { ...options, label: options.label ?? command.label }, reporter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumstate/runtime.ts:292

For singleFlight commands, aborting a second invocation does not return an interrupted result: runAtomCommand passes the signal into command.run, but createAtomCommandScheduler returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/runtime.ts around line 292:
For `singleFlight` commands, aborting a second invocation does not return an interrupted result: `runAtomCommand` passes the signal into `command.run`, but `createAtomCommandScheduler` returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

Comment threadapps/server/src/ws.ts
@@ -2420,11 +2428,13 @@ const makeWsRpcLayer = (
)
: Stream.empty;
const settingsUpdates = serverSettings.streamChanges.pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/ws.ts:2430

settingsUpdates is not subscribed until after loadServerConfig emits its snapshot, so a key change during discovery is missed and the client keeps a stale transcriptionServices catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/ws.ts around line 2430:
`settingsUpdates` is not subscribed until after `loadServerConfig` emits its snapshot, so a key change during discovery is missed and the client keeps a stale `transcriptionServices` catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

const operation = openAiApiKey.value.length > 0 ? "write-secret" : "remove-secret";
yield* (
openAiApiKey.value.length > 0
? secretStore.set(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/serverSettings.ts:669

A failed writeSettingsAtomically leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while settings.json still indicates a key is configured. persistProviderEnvironmentSecrets performs the secretStore.set/remove at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serverSettings.ts around line 669:
A failed `writeSettingsAtomically` leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while `settings.json` still indicates a key is configured. `persistProviderEnvironmentSecrets` performs the `secretStore.set`/`remove` at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

Comment on lines +147 to +152
const response = yield* httpClient
.post(OPENAI_TRANSCRIPTION_URL, {
headers: { Authorization: `Bearer ${apiKey}` },
body: HttpBody.formData(form),
})
.pipe(Effect.exit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect.exit swallows the request failure and the cause is never preserved or logged, so the returned 502 carries no diagnostic trail (and an interruption is reported as a request failure too). storeAttachmentUpload keeps the cause via Effect.logError(..., { cause }); consider doing the same here, and for the schemaBodyJson exit below (line 167) whose decode error is also discarded.

Suggested change
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(Effect.exit);
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(
Effect.tapError((cause)=>Effect.logError("OpenAI transcription request failed.",{ cause })),
Effect.exit,
);

Posted via Macroscope — Effect Service Conventions

) {
const [encoded, signature, unexpected] = token.split(".");
if (!encoded || !signature || unexpected) return null;
const secret = yield* loadSigningSecret.pipe(Effect.orElseSucceed(() => null));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The signing-key failure is dropped entirely here, so a broken secret store surfaces only as an opaque 404 with no cause anywhere in the logs. The sibling token validators (validateAttachmentUploadToken, AssetAccess) log the cause before falling back — consider matching them so the underlying failure is preserved.

Suggested change
constsecret=yield*loadSigningSecret.pipe(Effect.orElseSucceed(()=>null));
constsecret=yield*loadSigningSecret.pipe(
Effect.tapError((cause)=>
Effect.logError("Failed to load the transcription signing key.",{ cause }),
),
Effect.orElseSucceed(()=>null),
);

Posted via Macroscope — Effect Service Conventions

});
throwIfVoiceTranscriptionAborted(transcriptionSignal);
if (response.status < 200 || response.status >= 300) {
throw new Error(response.bodyText || `Transcription failed (${response.status}).`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This manufactures a bare Error purely so the catch below can wrap it as cause, and it puts the raw upstream response body into the error message. Throwing the domain error directly at the failure boundary keeps the structural code plus the safe HTTP status and drops the unbounded body text.

Suggested change
thrownewError(response.bodyText||`Transcription failed (${response.status}).`);
thrownewVoiceTranscriptionError(
"transcription-failed",
`The environment could not transcribe the recording (${response.status}).`,
);

Posted via Macroscope — Effect Service Conventions

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

const source =
selection.selectedSource ??
(selection.localTranscriber !== null ? "local" : selection.services[0]?.id);
if (source === "local" || source === undefined) return selection.localTranscriber;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale source blocks local transcription

Medium Severity

A persisted environment source is used even when that service is no longer advertised. isAvailable stays true whenever on-device transcription exists, so the mic remains shown, but getTranscriber still builds the environment transcriber and preparation fails. The settings row also disappears once services is empty, so there is no way to switch back to On this device.

Additional Locations (2)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

Comment on lines +2564 to +2577
<Input
aria-label="OpenAI transcription model"
className="w-full max-w-sm"
onBlur={() => {
const model = transcriptionModelDraft.trim();
if (model && model !== settings.transcription.model) {
updateSettings({ transcription: { model } });
} else {
setTranscriptionModelDraft(settings.transcription.model);
}
}}
onChange={(event) => setTranscriptionModelDraft(event.target.value)}
value={transcriptionModelDraft}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This row rebuilds the shared commit-on-blur field (DraftInput / useCommitOnBlur, already imported in this file) with a local draft plus a useEffect resync. The reconstruction drops Enter-to-commit, which every other server-backed settings text field supports, and the effect-based resync can overwrite an in-progress edit when the optimistic settings push lands, whereas useCommitOnBlur only resyncs while unfocused.

Suggested fix — use the primitive and delete transcriptionModelDraft's useState/useEffect at the top of GeneralSettingsPanel:

<DraftInputaria-label="OpenAI transcription model"className="w-full max-w-sm"value={settings.transcription.model}onCommit={(next)=>{constmodel=next.trim();if(model)updateSettings({transcription: { model }});}}/>

Posted via Macroscope — UI Consistency

Comment on lines +2523 to +2537
<div className="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event) => setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The trailing action is sized off the shared control scale used elsewhere: Input (default) is h-8.5 sm:h-7.5, while Button size="xs" is h-7 sm:h-6. Because the wrapper is flex without items-center, the shorter button stretches to flex-start and renders top-aligned and visibly smaller than the field. Every other input+action pair in settings matches sizes (default Input → default Button, compact → compact) and marks the action shrink-0 so the field absorbs the width.

Suggested change
<divclassName="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"
<divclassName="flex w-full max-w-sm items-center gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
className="shrink-0"
variant="outline"

Posted via Macroscope — UI Consistency

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces a substantial mobile-to-environment transcription workflow with new authenticated upload infrastructure, secret-backed OpenAI integration, and sensitive audio handling, while also changing the default transcription model. Unresolved findings include stale source selection and reusable upload URLs that can cause repeated provider calls and charges.

Not approved because:

  • 4 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

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

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Mobile voice dictation — mic button in the composer with BYOK OpenAI/Groq transcription

1 participant

@ahalekelly
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(mobile): environment-backed voice transcription - #9028

Open
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription
Open

feat(mobile): environment-backed voice transcription#9028
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription

Conversation

@ahalekelly

@ahalekellyahalekelly commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Generated with Claude Code

Problem

Mobile voice input currently requires Apple's on-device transcription, so it only exists on iOS 26+ iPhones — Android and older iOS have no mic button at all (#8718). docs/internals/voice-input.md (added in #8614) already specifies how environment-provided transcription should work, but it was unimplemented.

Change

Implements that spec as written:

  • Environment owns the credential. An OpenAI transcription API key is entered in web Settings → Voice input, persisted in the server's ServerSecretStore with its own explicit redaction — clients only ever see valueRedacted: true. Model defaults to gpt-transcribe, overridable in settings.
  • Capability-gated catalog. A transcription capability on ExecutionEnvironmentCapabilities, plus a derived transcriptionServices list (ids and labels only) on ServerConfig and the settingsUpdated payload. Older servers expose no remote choices; adding/removing the key updates availability live.
  • One-shot signed upload, nothing persisted. The client mints a short-lived signed URL over an authenticated RPC (mirroring the attachment-upload token pattern), POSTs the recording, and gets the transcript back in the same HTTP response. The server buffers in memory (25 MB cap, exact Content-Length enforcement) and forwards to OpenAI; audio is never written to disk, and aborts propagate to the upstream request.
  • Shared environment transcriber in packages/client-runtime implementing the existing VoiceTranscriber contract, with an injected platform transport (expo-file-system upload on mobile, matching the attachment transport split). Environment, service, and locale are captured when a recording starts; a disconnected environment reports unavailable rather than silently falling back.
  • Per-environment source picker on mobile (Settings → Environments): "On this device" vs the environment's service, stored per stable environmentId. Defaults to local when available, otherwise the environment service.
  • Android and iOS < 26 gain voice input automatically wherever an environment advertises transcription — the existing composer mic UI just becomes available.

Web/desktop voice capture remains out of scope, per the spec's boundaries; web only gains the settings section.

Relationship to #5213: that branch covers web/desktop BYOK dictation and has no apps/mobile code; this PR covers the mobile surface against the newer internals spec. Fixes#8718.

Verification

  • End-to-end on an iOS 26.5 simulator: spoken audio → recording → signed upload to the environment → OpenAI → transcript inserted in the composer ("T3 code voice transcription simulator verification."). Silence correctly surfaces the existing "No speech was detected." retry state.
  • Focused tests: server settings round-trip/redaction and transcription route (48), client-runtime environment transcriber and controller (68); targeted typecheck and lint on the five touched packages.

Screenshots

Mobile: per-environment pickerComposer micRecordingTranscript inserted

Web settings (environment key entry):

Implemented by GPT-5.6 Sol (Pi) with orchestration and code review by Claude Fable 5 (Claude Code).


Note

Medium Risk
Touches authenticated upload/transcription HTTP, secret persistence, and OpenAI forwarding with in-memory audio handling; mobile behavior changes when environments advertise transcription.

Overview
Adds environment-backed voice transcription so mobile can transcribe recordings via the connected server (OpenAI) when on-device speech is unavailable, not only on iOS 26+.

Server & contracts: New transcription capability, transcription.createUrl RPC, and a signed one-shot POST route that validates token/size, buffers audio in memory (no disk), and returns { text } from OpenAI. OpenAI API key and model live in server settings, stored in the secret store and redacted to clients; transcriptionServices is advertised on config and settingsUpdated events.

Shared client:createEnvironmentVoiceTranscriber mints URLs, uploads via injectable transport, and honors AbortSignal through atom commands. Server state exposes per-environment transcription service lists gated by capability.

Mobile: Composers pass environmentId into useVoiceInputController, which resolves local vs remote from persisted voiceTranscriptionSources. Settings → Environments adds a per-environment Voice transcription picker ("On this device" vs OpenAI). Mic availability becomes local or any advertised environment service.

Web: General settings gains Voice input (API key + model). Docs updated for mobile/environment flow.

Reviewed by Cursor Bugbot for commit bc83013. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add environment-backed voice transcription with OpenAI integration

  • Adds a signed-URL upload flow: clients call the new transcriptionCreateUrl WS RPC to get an expiring URL, upload audio, and the server forwards it to OpenAI, returning transcribed text. Token validation enforces exact Content-Length and rejects expired or malformed tokens.
  • Server settings now persist the OpenAI transcription API key in the secret store under TRANSCRIPTION_OPENAI_API_KEY_SECRET_NAME; the key is always redacted (value: "", valueRedacted: true) in settings responses. The web General settings panel exposes inputs for the API key and transcription model.
  • Mobile voice input controller accepts environmentId and selects between on-device transcription and environment-provided services based on user preferences and advertised transcriptionServices. A new per-environment row in the environments settings screen lets users pick the transcription source.
  • Client-runtime atom commands and queries now accept an AbortSignal via AtomCommandOptions, enabling cancellation-aware transcription flows in the environment transcriber.
  • Risk: the transcriptionCreateUrl RPC requires AuthOrchestrationOperateScope; callers without it will be rejected. The transcription POST endpoint at TRANSCRIPTION_ROUTE_PREFIX validates tokens server-side — any mismatch in signing key, expiry, or Content-Length returns an error status, so out-of-tree clients must follow the exact upload contract.
📊 Macroscope summarized bc83013. 29 files reviewed, 6 issues evaluated, 1 issue filtered, 4 comments posted

🗂️ Filtered Issues

apps/server/src/http.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 401: The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL. [ Cross-file consolidated ]

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Sep 1, 2026
};
});

export const validateTranscriptionToken = Effect.fn("Transcription.validateToken")(function* (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Hightranscription/Transcription.ts:75

The same valid transcription URL can be POSTed repeatedly until expiresAt, causing each request to invoke OpenAI and potentially incur another charge. validateTranscriptionToken only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.

Also found in 1 other location(s)

apps/server/src/http.ts:401

The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/transcription/Transcription.ts around line 75:
The same valid transcription URL can be POSTed repeatedly until `expiresAt`, causing each request to invoke OpenAI and potentially incur another charge. `validateTranscriptionToken` only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.
Also found in 1 other location(s):
- apps/server/src/http.ts:401 -- The route validates only the stateless signature and expiry at `validateTranscriptionToken`; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

): Promise<AtomCommandResult<A, E>> {
const result = await settleAtomCommandResult(() => command.run(registry, input));
const result = await settleAtomCommandResult(() => command.run(registry, input, options.signal));
reportAtomCommandResult(result, { ...options, label: options.label ?? command.label }, reporter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumstate/runtime.ts:292

For singleFlight commands, aborting a second invocation does not return an interrupted result: runAtomCommand passes the signal into command.run, but createAtomCommandScheduler returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/runtime.ts around line 292:
For `singleFlight` commands, aborting a second invocation does not return an interrupted result: `runAtomCommand` passes the signal into `command.run`, but `createAtomCommandScheduler` returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

Comment threadapps/server/src/ws.ts
@@ -2420,11 +2428,13 @@ const makeWsRpcLayer = (
)
: Stream.empty;
const settingsUpdates = serverSettings.streamChanges.pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/ws.ts:2430

settingsUpdates is not subscribed until after loadServerConfig emits its snapshot, so a key change during discovery is missed and the client keeps a stale transcriptionServices catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/ws.ts around line 2430:
`settingsUpdates` is not subscribed until after `loadServerConfig` emits its snapshot, so a key change during discovery is missed and the client keeps a stale `transcriptionServices` catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

const operation = openAiApiKey.value.length > 0 ? "write-secret" : "remove-secret";
yield* (
openAiApiKey.value.length > 0
? secretStore.set(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/serverSettings.ts:669

A failed writeSettingsAtomically leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while settings.json still indicates a key is configured. persistProviderEnvironmentSecrets performs the secretStore.set/remove at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serverSettings.ts around line 669:
A failed `writeSettingsAtomically` leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while `settings.json` still indicates a key is configured. `persistProviderEnvironmentSecrets` performs the `secretStore.set`/`remove` at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

Comment on lines +147 to +152
const response = yield* httpClient
.post(OPENAI_TRANSCRIPTION_URL, {
headers: { Authorization: `Bearer ${apiKey}` },
body: HttpBody.formData(form),
})
.pipe(Effect.exit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect.exit swallows the request failure and the cause is never preserved or logged, so the returned 502 carries no diagnostic trail (and an interruption is reported as a request failure too). storeAttachmentUpload keeps the cause via Effect.logError(..., { cause }); consider doing the same here, and for the schemaBodyJson exit below (line 167) whose decode error is also discarded.

Suggested change
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(Effect.exit);
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(
Effect.tapError((cause)=>Effect.logError("OpenAI transcription request failed.",{ cause })),
Effect.exit,
);

Posted via Macroscope — Effect Service Conventions

) {
const [encoded, signature, unexpected] = token.split(".");
if (!encoded || !signature || unexpected) return null;
const secret = yield* loadSigningSecret.pipe(Effect.orElseSucceed(() => null));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The signing-key failure is dropped entirely here, so a broken secret store surfaces only as an opaque 404 with no cause anywhere in the logs. The sibling token validators (validateAttachmentUploadToken, AssetAccess) log the cause before falling back — consider matching them so the underlying failure is preserved.

Suggested change
constsecret=yield*loadSigningSecret.pipe(Effect.orElseSucceed(()=>null));
constsecret=yield*loadSigningSecret.pipe(
Effect.tapError((cause)=>
Effect.logError("Failed to load the transcription signing key.",{ cause }),
),
Effect.orElseSucceed(()=>null),
);

Posted via Macroscope — Effect Service Conventions

});
throwIfVoiceTranscriptionAborted(transcriptionSignal);
if (response.status < 200 || response.status >= 300) {
throw new Error(response.bodyText || `Transcription failed (${response.status}).`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This manufactures a bare Error purely so the catch below can wrap it as cause, and it puts the raw upstream response body into the error message. Throwing the domain error directly at the failure boundary keeps the structural code plus the safe HTTP status and drops the unbounded body text.

Suggested change
thrownewError(response.bodyText||`Transcription failed (${response.status}).`);
thrownewVoiceTranscriptionError(
"transcription-failed",
`The environment could not transcribe the recording (${response.status}).`,
);

Posted via Macroscope — Effect Service Conventions

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

const source =
selection.selectedSource ??
(selection.localTranscriber !== null ? "local" : selection.services[0]?.id);
if (source === "local" || source === undefined) return selection.localTranscriber;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale source blocks local transcription

Medium Severity

A persisted environment source is used even when that service is no longer advertised. isAvailable stays true whenever on-device transcription exists, so the mic remains shown, but getTranscriber still builds the environment transcriber and preparation fails. The settings row also disappears once services is empty, so there is no way to switch back to On this device.

Additional Locations (2)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

Comment on lines +2564 to +2577
<Input
aria-label="OpenAI transcription model"
className="w-full max-w-sm"
onBlur={() => {
const model = transcriptionModelDraft.trim();
if (model && model !== settings.transcription.model) {
updateSettings({ transcription: { model } });
} else {
setTranscriptionModelDraft(settings.transcription.model);
}
}}
onChange={(event) => setTranscriptionModelDraft(event.target.value)}
value={transcriptionModelDraft}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This row rebuilds the shared commit-on-blur field (DraftInput / useCommitOnBlur, already imported in this file) with a local draft plus a useEffect resync. The reconstruction drops Enter-to-commit, which every other server-backed settings text field supports, and the effect-based resync can overwrite an in-progress edit when the optimistic settings push lands, whereas useCommitOnBlur only resyncs while unfocused.

Suggested fix — use the primitive and delete transcriptionModelDraft's useState/useEffect at the top of GeneralSettingsPanel:

<DraftInputaria-label="OpenAI transcription model"className="w-full max-w-sm"value={settings.transcription.model}onCommit={(next)=>{constmodel=next.trim();if(model)updateSettings({transcription: { model }});}}/>

Posted via Macroscope — UI Consistency

Comment on lines +2523 to +2537
<div className="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event) => setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The trailing action is sized off the shared control scale used elsewhere: Input (default) is h-8.5 sm:h-7.5, while Button size="xs" is h-7 sm:h-6. Because the wrapper is flex without items-center, the shorter button stretches to flex-start and renders top-aligned and visibly smaller than the field. Every other input+action pair in settings matches sizes (default Input → default Button, compact → compact) and marks the action shrink-0 so the field absorbs the width.

Suggested change
<divclassName="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"
<divclassName="flex w-full max-w-sm items-center gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
className="shrink-0"
variant="outline"

Posted via Macroscope — UI Consistency

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces a substantial mobile-to-environment transcription workflow with new authenticated upload infrastructure, secret-backed OpenAI integration, and sensitive audio handling, while also changing the default transcription model. Unresolved findings include stale source selection and reusable upload URLs that can cause repeated provider calls and charges.

Not approved because:

  • 4 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

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

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Mobile voice dictation — mic button in the composer with BYOK OpenAI/Groq transcription

1 participant

@ahalekelly
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(mobile): environment-backed voice transcription - #9028

Open
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription
Open

feat(mobile): environment-backed voice transcription#9028
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription

Conversation

@ahalekelly

@ahalekellyahalekelly commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Generated with Claude Code

Problem

Mobile voice input currently requires Apple's on-device transcription, so it only exists on iOS 26+ iPhones — Android and older iOS have no mic button at all (#8718). docs/internals/voice-input.md (added in #8614) already specifies how environment-provided transcription should work, but it was unimplemented.

Change

Implements that spec as written:

  • Environment owns the credential. An OpenAI transcription API key is entered in web Settings → Voice input, persisted in the server's ServerSecretStore with its own explicit redaction — clients only ever see valueRedacted: true. Model defaults to gpt-transcribe, overridable in settings.
  • Capability-gated catalog. A transcription capability on ExecutionEnvironmentCapabilities, plus a derived transcriptionServices list (ids and labels only) on ServerConfig and the settingsUpdated payload. Older servers expose no remote choices; adding/removing the key updates availability live.
  • One-shot signed upload, nothing persisted. The client mints a short-lived signed URL over an authenticated RPC (mirroring the attachment-upload token pattern), POSTs the recording, and gets the transcript back in the same HTTP response. The server buffers in memory (25 MB cap, exact Content-Length enforcement) and forwards to OpenAI; audio is never written to disk, and aborts propagate to the upstream request.
  • Shared environment transcriber in packages/client-runtime implementing the existing VoiceTranscriber contract, with an injected platform transport (expo-file-system upload on mobile, matching the attachment transport split). Environment, service, and locale are captured when a recording starts; a disconnected environment reports unavailable rather than silently falling back.
  • Per-environment source picker on mobile (Settings → Environments): "On this device" vs the environment's service, stored per stable environmentId. Defaults to local when available, otherwise the environment service.
  • Android and iOS < 26 gain voice input automatically wherever an environment advertises transcription — the existing composer mic UI just becomes available.

Web/desktop voice capture remains out of scope, per the spec's boundaries; web only gains the settings section.

Relationship to #5213: that branch covers web/desktop BYOK dictation and has no apps/mobile code; this PR covers the mobile surface against the newer internals spec. Fixes#8718.

Verification

  • End-to-end on an iOS 26.5 simulator: spoken audio → recording → signed upload to the environment → OpenAI → transcript inserted in the composer ("T3 code voice transcription simulator verification."). Silence correctly surfaces the existing "No speech was detected." retry state.
  • Focused tests: server settings round-trip/redaction and transcription route (48), client-runtime environment transcriber and controller (68); targeted typecheck and lint on the five touched packages.

Screenshots

Mobile: per-environment pickerComposer micRecordingTranscript inserted

Web settings (environment key entry):

Implemented by GPT-5.6 Sol (Pi) with orchestration and code review by Claude Fable 5 (Claude Code).


Note

Medium Risk
Touches authenticated upload/transcription HTTP, secret persistence, and OpenAI forwarding with in-memory audio handling; mobile behavior changes when environments advertise transcription.

Overview
Adds environment-backed voice transcription so mobile can transcribe recordings via the connected server (OpenAI) when on-device speech is unavailable, not only on iOS 26+.

Server & contracts: New transcription capability, transcription.createUrl RPC, and a signed one-shot POST route that validates token/size, buffers audio in memory (no disk), and returns { text } from OpenAI. OpenAI API key and model live in server settings, stored in the secret store and redacted to clients; transcriptionServices is advertised on config and settingsUpdated events.

Shared client:createEnvironmentVoiceTranscriber mints URLs, uploads via injectable transport, and honors AbortSignal through atom commands. Server state exposes per-environment transcription service lists gated by capability.

Mobile: Composers pass environmentId into useVoiceInputController, which resolves local vs remote from persisted voiceTranscriptionSources. Settings → Environments adds a per-environment Voice transcription picker ("On this device" vs OpenAI). Mic availability becomes local or any advertised environment service.

Web: General settings gains Voice input (API key + model). Docs updated for mobile/environment flow.

Reviewed by Cursor Bugbot for commit bc83013. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add environment-backed voice transcription with OpenAI integration

  • Adds a signed-URL upload flow: clients call the new transcriptionCreateUrl WS RPC to get an expiring URL, upload audio, and the server forwards it to OpenAI, returning transcribed text. Token validation enforces exact Content-Length and rejects expired or malformed tokens.
  • Server settings now persist the OpenAI transcription API key in the secret store under TRANSCRIPTION_OPENAI_API_KEY_SECRET_NAME; the key is always redacted (value: "", valueRedacted: true) in settings responses. The web General settings panel exposes inputs for the API key and transcription model.
  • Mobile voice input controller accepts environmentId and selects between on-device transcription and environment-provided services based on user preferences and advertised transcriptionServices. A new per-environment row in the environments settings screen lets users pick the transcription source.
  • Client-runtime atom commands and queries now accept an AbortSignal via AtomCommandOptions, enabling cancellation-aware transcription flows in the environment transcriber.
  • Risk: the transcriptionCreateUrl RPC requires AuthOrchestrationOperateScope; callers without it will be rejected. The transcription POST endpoint at TRANSCRIPTION_ROUTE_PREFIX validates tokens server-side — any mismatch in signing key, expiry, or Content-Length returns an error status, so out-of-tree clients must follow the exact upload contract.
📊 Macroscope summarized bc83013. 29 files reviewed, 6 issues evaluated, 1 issue filtered, 4 comments posted

🗂️ Filtered Issues

apps/server/src/http.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 401: The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL. [ Cross-file consolidated ]

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Sep 1, 2026
};
});

export const validateTranscriptionToken = Effect.fn("Transcription.validateToken")(function* (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Hightranscription/Transcription.ts:75

The same valid transcription URL can be POSTed repeatedly until expiresAt, causing each request to invoke OpenAI and potentially incur another charge. validateTranscriptionToken only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.

Also found in 1 other location(s)

apps/server/src/http.ts:401

The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/transcription/Transcription.ts around line 75:
The same valid transcription URL can be POSTed repeatedly until `expiresAt`, causing each request to invoke OpenAI and potentially incur another charge. `validateTranscriptionToken` only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.
Also found in 1 other location(s):
- apps/server/src/http.ts:401 -- The route validates only the stateless signature and expiry at `validateTranscriptionToken`; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

): Promise<AtomCommandResult<A, E>> {
const result = await settleAtomCommandResult(() => command.run(registry, input));
const result = await settleAtomCommandResult(() => command.run(registry, input, options.signal));
reportAtomCommandResult(result, { ...options, label: options.label ?? command.label }, reporter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumstate/runtime.ts:292

For singleFlight commands, aborting a second invocation does not return an interrupted result: runAtomCommand passes the signal into command.run, but createAtomCommandScheduler returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/runtime.ts around line 292:
For `singleFlight` commands, aborting a second invocation does not return an interrupted result: `runAtomCommand` passes the signal into `command.run`, but `createAtomCommandScheduler` returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

Comment threadapps/server/src/ws.ts
@@ -2420,11 +2428,13 @@ const makeWsRpcLayer = (
)
: Stream.empty;
const settingsUpdates = serverSettings.streamChanges.pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/ws.ts:2430

settingsUpdates is not subscribed until after loadServerConfig emits its snapshot, so a key change during discovery is missed and the client keeps a stale transcriptionServices catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/ws.ts around line 2430:
`settingsUpdates` is not subscribed until after `loadServerConfig` emits its snapshot, so a key change during discovery is missed and the client keeps a stale `transcriptionServices` catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

const operation = openAiApiKey.value.length > 0 ? "write-secret" : "remove-secret";
yield* (
openAiApiKey.value.length > 0
? secretStore.set(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/serverSettings.ts:669

A failed writeSettingsAtomically leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while settings.json still indicates a key is configured. persistProviderEnvironmentSecrets performs the secretStore.set/remove at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serverSettings.ts around line 669:
A failed `writeSettingsAtomically` leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while `settings.json` still indicates a key is configured. `persistProviderEnvironmentSecrets` performs the `secretStore.set`/`remove` at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

Comment on lines +147 to +152
const response = yield* httpClient
.post(OPENAI_TRANSCRIPTION_URL, {
headers: { Authorization: `Bearer ${apiKey}` },
body: HttpBody.formData(form),
})
.pipe(Effect.exit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect.exit swallows the request failure and the cause is never preserved or logged, so the returned 502 carries no diagnostic trail (and an interruption is reported as a request failure too). storeAttachmentUpload keeps the cause via Effect.logError(..., { cause }); consider doing the same here, and for the schemaBodyJson exit below (line 167) whose decode error is also discarded.

Suggested change
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(Effect.exit);
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(
Effect.tapError((cause)=>Effect.logError("OpenAI transcription request failed.",{ cause })),
Effect.exit,
);

Posted via Macroscope — Effect Service Conventions

) {
const [encoded, signature, unexpected] = token.split(".");
if (!encoded || !signature || unexpected) return null;
const secret = yield* loadSigningSecret.pipe(Effect.orElseSucceed(() => null));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The signing-key failure is dropped entirely here, so a broken secret store surfaces only as an opaque 404 with no cause anywhere in the logs. The sibling token validators (validateAttachmentUploadToken, AssetAccess) log the cause before falling back — consider matching them so the underlying failure is preserved.

Suggested change
constsecret=yield*loadSigningSecret.pipe(Effect.orElseSucceed(()=>null));
constsecret=yield*loadSigningSecret.pipe(
Effect.tapError((cause)=>
Effect.logError("Failed to load the transcription signing key.",{ cause }),
),
Effect.orElseSucceed(()=>null),
);

Posted via Macroscope — Effect Service Conventions

});
throwIfVoiceTranscriptionAborted(transcriptionSignal);
if (response.status < 200 || response.status >= 300) {
throw new Error(response.bodyText || `Transcription failed (${response.status}).`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This manufactures a bare Error purely so the catch below can wrap it as cause, and it puts the raw upstream response body into the error message. Throwing the domain error directly at the failure boundary keeps the structural code plus the safe HTTP status and drops the unbounded body text.

Suggested change
thrownewError(response.bodyText||`Transcription failed (${response.status}).`);
thrownewVoiceTranscriptionError(
"transcription-failed",
`The environment could not transcribe the recording (${response.status}).`,
);

Posted via Macroscope — Effect Service Conventions

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

const source =
selection.selectedSource ??
(selection.localTranscriber !== null ? "local" : selection.services[0]?.id);
if (source === "local" || source === undefined) return selection.localTranscriber;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale source blocks local transcription

Medium Severity

A persisted environment source is used even when that service is no longer advertised. isAvailable stays true whenever on-device transcription exists, so the mic remains shown, but getTranscriber still builds the environment transcriber and preparation fails. The settings row also disappears once services is empty, so there is no way to switch back to On this device.

Additional Locations (2)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

Comment on lines +2564 to +2577
<Input
aria-label="OpenAI transcription model"
className="w-full max-w-sm"
onBlur={() => {
const model = transcriptionModelDraft.trim();
if (model && model !== settings.transcription.model) {
updateSettings({ transcription: { model } });
} else {
setTranscriptionModelDraft(settings.transcription.model);
}
}}
onChange={(event) => setTranscriptionModelDraft(event.target.value)}
value={transcriptionModelDraft}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This row rebuilds the shared commit-on-blur field (DraftInput / useCommitOnBlur, already imported in this file) with a local draft plus a useEffect resync. The reconstruction drops Enter-to-commit, which every other server-backed settings text field supports, and the effect-based resync can overwrite an in-progress edit when the optimistic settings push lands, whereas useCommitOnBlur only resyncs while unfocused.

Suggested fix — use the primitive and delete transcriptionModelDraft's useState/useEffect at the top of GeneralSettingsPanel:

<DraftInputaria-label="OpenAI transcription model"className="w-full max-w-sm"value={settings.transcription.model}onCommit={(next)=>{constmodel=next.trim();if(model)updateSettings({transcription: { model }});}}/>

Posted via Macroscope — UI Consistency

Comment on lines +2523 to +2537
<div className="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event) => setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The trailing action is sized off the shared control scale used elsewhere: Input (default) is h-8.5 sm:h-7.5, while Button size="xs" is h-7 sm:h-6. Because the wrapper is flex without items-center, the shorter button stretches to flex-start and renders top-aligned and visibly smaller than the field. Every other input+action pair in settings matches sizes (default Input → default Button, compact → compact) and marks the action shrink-0 so the field absorbs the width.

Suggested change
<divclassName="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"
<divclassName="flex w-full max-w-sm items-center gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
className="shrink-0"
variant="outline"

Posted via Macroscope — UI Consistency

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces a substantial mobile-to-environment transcription workflow with new authenticated upload infrastructure, secret-backed OpenAI integration, and sensitive audio handling, while also changing the default transcription model. Unresolved findings include stale source selection and reusable upload URLs that can cause repeated provider calls and charges.

Not approved because:

  • 4 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

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

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Mobile voice dictation — mic button in the composer with BYOK OpenAI/Groq transcription

1 participant

@ahalekelly
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(mobile): environment-backed voice transcription - #9028

Open
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription
Open

feat(mobile): environment-backed voice transcription#9028
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription

Conversation

@ahalekelly

@ahalekellyahalekelly commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Generated with Claude Code

Problem

Mobile voice input currently requires Apple's on-device transcription, so it only exists on iOS 26+ iPhones — Android and older iOS have no mic button at all (#8718). docs/internals/voice-input.md (added in #8614) already specifies how environment-provided transcription should work, but it was unimplemented.

Change

Implements that spec as written:

  • Environment owns the credential. An OpenAI transcription API key is entered in web Settings → Voice input, persisted in the server's ServerSecretStore with its own explicit redaction — clients only ever see valueRedacted: true. Model defaults to gpt-transcribe, overridable in settings.
  • Capability-gated catalog. A transcription capability on ExecutionEnvironmentCapabilities, plus a derived transcriptionServices list (ids and labels only) on ServerConfig and the settingsUpdated payload. Older servers expose no remote choices; adding/removing the key updates availability live.
  • One-shot signed upload, nothing persisted. The client mints a short-lived signed URL over an authenticated RPC (mirroring the attachment-upload token pattern), POSTs the recording, and gets the transcript back in the same HTTP response. The server buffers in memory (25 MB cap, exact Content-Length enforcement) and forwards to OpenAI; audio is never written to disk, and aborts propagate to the upstream request.
  • Shared environment transcriber in packages/client-runtime implementing the existing VoiceTranscriber contract, with an injected platform transport (expo-file-system upload on mobile, matching the attachment transport split). Environment, service, and locale are captured when a recording starts; a disconnected environment reports unavailable rather than silently falling back.
  • Per-environment source picker on mobile (Settings → Environments): "On this device" vs the environment's service, stored per stable environmentId. Defaults to local when available, otherwise the environment service.
  • Android and iOS < 26 gain voice input automatically wherever an environment advertises transcription — the existing composer mic UI just becomes available.

Web/desktop voice capture remains out of scope, per the spec's boundaries; web only gains the settings section.

Relationship to #5213: that branch covers web/desktop BYOK dictation and has no apps/mobile code; this PR covers the mobile surface against the newer internals spec. Fixes#8718.

Verification

  • End-to-end on an iOS 26.5 simulator: spoken audio → recording → signed upload to the environment → OpenAI → transcript inserted in the composer ("T3 code voice transcription simulator verification."). Silence correctly surfaces the existing "No speech was detected." retry state.
  • Focused tests: server settings round-trip/redaction and transcription route (48), client-runtime environment transcriber and controller (68); targeted typecheck and lint on the five touched packages.

Screenshots

Mobile: per-environment pickerComposer micRecordingTranscript inserted

Web settings (environment key entry):

Implemented by GPT-5.6 Sol (Pi) with orchestration and code review by Claude Fable 5 (Claude Code).


Note

Medium Risk
Touches authenticated upload/transcription HTTP, secret persistence, and OpenAI forwarding with in-memory audio handling; mobile behavior changes when environments advertise transcription.

Overview
Adds environment-backed voice transcription so mobile can transcribe recordings via the connected server (OpenAI) when on-device speech is unavailable, not only on iOS 26+.

Server & contracts: New transcription capability, transcription.createUrl RPC, and a signed one-shot POST route that validates token/size, buffers audio in memory (no disk), and returns { text } from OpenAI. OpenAI API key and model live in server settings, stored in the secret store and redacted to clients; transcriptionServices is advertised on config and settingsUpdated events.

Shared client:createEnvironmentVoiceTranscriber mints URLs, uploads via injectable transport, and honors AbortSignal through atom commands. Server state exposes per-environment transcription service lists gated by capability.

Mobile: Composers pass environmentId into useVoiceInputController, which resolves local vs remote from persisted voiceTranscriptionSources. Settings → Environments adds a per-environment Voice transcription picker ("On this device" vs OpenAI). Mic availability becomes local or any advertised environment service.

Web: General settings gains Voice input (API key + model). Docs updated for mobile/environment flow.

Reviewed by Cursor Bugbot for commit bc83013. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add environment-backed voice transcription with OpenAI integration

  • Adds a signed-URL upload flow: clients call the new transcriptionCreateUrl WS RPC to get an expiring URL, upload audio, and the server forwards it to OpenAI, returning transcribed text. Token validation enforces exact Content-Length and rejects expired or malformed tokens.
  • Server settings now persist the OpenAI transcription API key in the secret store under TRANSCRIPTION_OPENAI_API_KEY_SECRET_NAME; the key is always redacted (value: "", valueRedacted: true) in settings responses. The web General settings panel exposes inputs for the API key and transcription model.
  • Mobile voice input controller accepts environmentId and selects between on-device transcription and environment-provided services based on user preferences and advertised transcriptionServices. A new per-environment row in the environments settings screen lets users pick the transcription source.
  • Client-runtime atom commands and queries now accept an AbortSignal via AtomCommandOptions, enabling cancellation-aware transcription flows in the environment transcriber.
  • Risk: the transcriptionCreateUrl RPC requires AuthOrchestrationOperateScope; callers without it will be rejected. The transcription POST endpoint at TRANSCRIPTION_ROUTE_PREFIX validates tokens server-side — any mismatch in signing key, expiry, or Content-Length returns an error status, so out-of-tree clients must follow the exact upload contract.
📊 Macroscope summarized bc83013. 29 files reviewed, 6 issues evaluated, 1 issue filtered, 4 comments posted

🗂️ Filtered Issues

apps/server/src/http.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 401: The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL. [ Cross-file consolidated ]

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Sep 1, 2026
};
});

export const validateTranscriptionToken = Effect.fn("Transcription.validateToken")(function* (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Hightranscription/Transcription.ts:75

The same valid transcription URL can be POSTed repeatedly until expiresAt, causing each request to invoke OpenAI and potentially incur another charge. validateTranscriptionToken only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.

Also found in 1 other location(s)

apps/server/src/http.ts:401

The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/transcription/Transcription.ts around line 75:
The same valid transcription URL can be POSTed repeatedly until `expiresAt`, causing each request to invoke OpenAI and potentially incur another charge. `validateTranscriptionToken` only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.
Also found in 1 other location(s):
- apps/server/src/http.ts:401 -- The route validates only the stateless signature and expiry at `validateTranscriptionToken`; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

): Promise<AtomCommandResult<A, E>> {
const result = await settleAtomCommandResult(() => command.run(registry, input));
const result = await settleAtomCommandResult(() => command.run(registry, input, options.signal));
reportAtomCommandResult(result, { ...options, label: options.label ?? command.label }, reporter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumstate/runtime.ts:292

For singleFlight commands, aborting a second invocation does not return an interrupted result: runAtomCommand passes the signal into command.run, but createAtomCommandScheduler returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/runtime.ts around line 292:
For `singleFlight` commands, aborting a second invocation does not return an interrupted result: `runAtomCommand` passes the signal into `command.run`, but `createAtomCommandScheduler` returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

Comment threadapps/server/src/ws.ts
@@ -2420,11 +2428,13 @@ const makeWsRpcLayer = (
)
: Stream.empty;
const settingsUpdates = serverSettings.streamChanges.pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/ws.ts:2430

settingsUpdates is not subscribed until after loadServerConfig emits its snapshot, so a key change during discovery is missed and the client keeps a stale transcriptionServices catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/ws.ts around line 2430:
`settingsUpdates` is not subscribed until after `loadServerConfig` emits its snapshot, so a key change during discovery is missed and the client keeps a stale `transcriptionServices` catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

const operation = openAiApiKey.value.length > 0 ? "write-secret" : "remove-secret";
yield* (
openAiApiKey.value.length > 0
? secretStore.set(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/serverSettings.ts:669

A failed writeSettingsAtomically leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while settings.json still indicates a key is configured. persistProviderEnvironmentSecrets performs the secretStore.set/remove at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serverSettings.ts around line 669:
A failed `writeSettingsAtomically` leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while `settings.json` still indicates a key is configured. `persistProviderEnvironmentSecrets` performs the `secretStore.set`/`remove` at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

Comment on lines +147 to +152
const response = yield* httpClient
.post(OPENAI_TRANSCRIPTION_URL, {
headers: { Authorization: `Bearer ${apiKey}` },
body: HttpBody.formData(form),
})
.pipe(Effect.exit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect.exit swallows the request failure and the cause is never preserved or logged, so the returned 502 carries no diagnostic trail (and an interruption is reported as a request failure too). storeAttachmentUpload keeps the cause via Effect.logError(..., { cause }); consider doing the same here, and for the schemaBodyJson exit below (line 167) whose decode error is also discarded.

Suggested change
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(Effect.exit);
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(
Effect.tapError((cause)=>Effect.logError("OpenAI transcription request failed.",{ cause })),
Effect.exit,
);

Posted via Macroscope — Effect Service Conventions

) {
const [encoded, signature, unexpected] = token.split(".");
if (!encoded || !signature || unexpected) return null;
const secret = yield* loadSigningSecret.pipe(Effect.orElseSucceed(() => null));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The signing-key failure is dropped entirely here, so a broken secret store surfaces only as an opaque 404 with no cause anywhere in the logs. The sibling token validators (validateAttachmentUploadToken, AssetAccess) log the cause before falling back — consider matching them so the underlying failure is preserved.

Suggested change
constsecret=yield*loadSigningSecret.pipe(Effect.orElseSucceed(()=>null));
constsecret=yield*loadSigningSecret.pipe(
Effect.tapError((cause)=>
Effect.logError("Failed to load the transcription signing key.",{ cause }),
),
Effect.orElseSucceed(()=>null),
);

Posted via Macroscope — Effect Service Conventions

});
throwIfVoiceTranscriptionAborted(transcriptionSignal);
if (response.status < 200 || response.status >= 300) {
throw new Error(response.bodyText || `Transcription failed (${response.status}).`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This manufactures a bare Error purely so the catch below can wrap it as cause, and it puts the raw upstream response body into the error message. Throwing the domain error directly at the failure boundary keeps the structural code plus the safe HTTP status and drops the unbounded body text.

Suggested change
thrownewError(response.bodyText||`Transcription failed (${response.status}).`);
thrownewVoiceTranscriptionError(
"transcription-failed",
`The environment could not transcribe the recording (${response.status}).`,
);

Posted via Macroscope — Effect Service Conventions

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

const source =
selection.selectedSource ??
(selection.localTranscriber !== null ? "local" : selection.services[0]?.id);
if (source === "local" || source === undefined) return selection.localTranscriber;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale source blocks local transcription

Medium Severity

A persisted environment source is used even when that service is no longer advertised. isAvailable stays true whenever on-device transcription exists, so the mic remains shown, but getTranscriber still builds the environment transcriber and preparation fails. The settings row also disappears once services is empty, so there is no way to switch back to On this device.

Additional Locations (2)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

Comment on lines +2564 to +2577
<Input
aria-label="OpenAI transcription model"
className="w-full max-w-sm"
onBlur={() => {
const model = transcriptionModelDraft.trim();
if (model && model !== settings.transcription.model) {
updateSettings({ transcription: { model } });
} else {
setTranscriptionModelDraft(settings.transcription.model);
}
}}
onChange={(event) => setTranscriptionModelDraft(event.target.value)}
value={transcriptionModelDraft}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This row rebuilds the shared commit-on-blur field (DraftInput / useCommitOnBlur, already imported in this file) with a local draft plus a useEffect resync. The reconstruction drops Enter-to-commit, which every other server-backed settings text field supports, and the effect-based resync can overwrite an in-progress edit when the optimistic settings push lands, whereas useCommitOnBlur only resyncs while unfocused.

Suggested fix — use the primitive and delete transcriptionModelDraft's useState/useEffect at the top of GeneralSettingsPanel:

<DraftInputaria-label="OpenAI transcription model"className="w-full max-w-sm"value={settings.transcription.model}onCommit={(next)=>{constmodel=next.trim();if(model)updateSettings({transcription: { model }});}}/>

Posted via Macroscope — UI Consistency

Comment on lines +2523 to +2537
<div className="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event) => setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The trailing action is sized off the shared control scale used elsewhere: Input (default) is h-8.5 sm:h-7.5, while Button size="xs" is h-7 sm:h-6. Because the wrapper is flex without items-center, the shorter button stretches to flex-start and renders top-aligned and visibly smaller than the field. Every other input+action pair in settings matches sizes (default Input → default Button, compact → compact) and marks the action shrink-0 so the field absorbs the width.

Suggested change
<divclassName="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"
<divclassName="flex w-full max-w-sm items-center gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
className="shrink-0"
variant="outline"

Posted via Macroscope — UI Consistency

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces a substantial mobile-to-environment transcription workflow with new authenticated upload infrastructure, secret-backed OpenAI integration, and sensitive audio handling, while also changing the default transcription model. Unresolved findings include stale source selection and reusable upload URLs that can cause repeated provider calls and charges.

Not approved because:

  • 4 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

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

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Mobile voice dictation — mic button in the composer with BYOK OpenAI/Groq transcription

1 participant

@ahalekelly
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(mobile): environment-backed voice transcription - #9028

Open
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription
Open

feat(mobile): environment-backed voice transcription#9028
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription

Conversation

@ahalekelly

@ahalekellyahalekelly commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Generated with Claude Code

Problem

Mobile voice input currently requires Apple's on-device transcription, so it only exists on iOS 26+ iPhones — Android and older iOS have no mic button at all (#8718). docs/internals/voice-input.md (added in #8614) already specifies how environment-provided transcription should work, but it was unimplemented.

Change

Implements that spec as written:

  • Environment owns the credential. An OpenAI transcription API key is entered in web Settings → Voice input, persisted in the server's ServerSecretStore with its own explicit redaction — clients only ever see valueRedacted: true. Model defaults to gpt-transcribe, overridable in settings.
  • Capability-gated catalog. A transcription capability on ExecutionEnvironmentCapabilities, plus a derived transcriptionServices list (ids and labels only) on ServerConfig and the settingsUpdated payload. Older servers expose no remote choices; adding/removing the key updates availability live.
  • One-shot signed upload, nothing persisted. The client mints a short-lived signed URL over an authenticated RPC (mirroring the attachment-upload token pattern), POSTs the recording, and gets the transcript back in the same HTTP response. The server buffers in memory (25 MB cap, exact Content-Length enforcement) and forwards to OpenAI; audio is never written to disk, and aborts propagate to the upstream request.
  • Shared environment transcriber in packages/client-runtime implementing the existing VoiceTranscriber contract, with an injected platform transport (expo-file-system upload on mobile, matching the attachment transport split). Environment, service, and locale are captured when a recording starts; a disconnected environment reports unavailable rather than silently falling back.
  • Per-environment source picker on mobile (Settings → Environments): "On this device" vs the environment's service, stored per stable environmentId. Defaults to local when available, otherwise the environment service.
  • Android and iOS < 26 gain voice input automatically wherever an environment advertises transcription — the existing composer mic UI just becomes available.

Web/desktop voice capture remains out of scope, per the spec's boundaries; web only gains the settings section.

Relationship to #5213: that branch covers web/desktop BYOK dictation and has no apps/mobile code; this PR covers the mobile surface against the newer internals spec. Fixes#8718.

Verification

  • End-to-end on an iOS 26.5 simulator: spoken audio → recording → signed upload to the environment → OpenAI → transcript inserted in the composer ("T3 code voice transcription simulator verification."). Silence correctly surfaces the existing "No speech was detected." retry state.
  • Focused tests: server settings round-trip/redaction and transcription route (48), client-runtime environment transcriber and controller (68); targeted typecheck and lint on the five touched packages.

Screenshots

Mobile: per-environment pickerComposer micRecordingTranscript inserted

Web settings (environment key entry):

Implemented by GPT-5.6 Sol (Pi) with orchestration and code review by Claude Fable 5 (Claude Code).


Note

Medium Risk
Touches authenticated upload/transcription HTTP, secret persistence, and OpenAI forwarding with in-memory audio handling; mobile behavior changes when environments advertise transcription.

Overview
Adds environment-backed voice transcription so mobile can transcribe recordings via the connected server (OpenAI) when on-device speech is unavailable, not only on iOS 26+.

Server & contracts: New transcription capability, transcription.createUrl RPC, and a signed one-shot POST route that validates token/size, buffers audio in memory (no disk), and returns { text } from OpenAI. OpenAI API key and model live in server settings, stored in the secret store and redacted to clients; transcriptionServices is advertised on config and settingsUpdated events.

Shared client:createEnvironmentVoiceTranscriber mints URLs, uploads via injectable transport, and honors AbortSignal through atom commands. Server state exposes per-environment transcription service lists gated by capability.

Mobile: Composers pass environmentId into useVoiceInputController, which resolves local vs remote from persisted voiceTranscriptionSources. Settings → Environments adds a per-environment Voice transcription picker ("On this device" vs OpenAI). Mic availability becomes local or any advertised environment service.

Web: General settings gains Voice input (API key + model). Docs updated for mobile/environment flow.

Reviewed by Cursor Bugbot for commit bc83013. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add environment-backed voice transcription with OpenAI integration

  • Adds a signed-URL upload flow: clients call the new transcriptionCreateUrl WS RPC to get an expiring URL, upload audio, and the server forwards it to OpenAI, returning transcribed text. Token validation enforces exact Content-Length and rejects expired or malformed tokens.
  • Server settings now persist the OpenAI transcription API key in the secret store under TRANSCRIPTION_OPENAI_API_KEY_SECRET_NAME; the key is always redacted (value: "", valueRedacted: true) in settings responses. The web General settings panel exposes inputs for the API key and transcription model.
  • Mobile voice input controller accepts environmentId and selects between on-device transcription and environment-provided services based on user preferences and advertised transcriptionServices. A new per-environment row in the environments settings screen lets users pick the transcription source.
  • Client-runtime atom commands and queries now accept an AbortSignal via AtomCommandOptions, enabling cancellation-aware transcription flows in the environment transcriber.
  • Risk: the transcriptionCreateUrl RPC requires AuthOrchestrationOperateScope; callers without it will be rejected. The transcription POST endpoint at TRANSCRIPTION_ROUTE_PREFIX validates tokens server-side — any mismatch in signing key, expiry, or Content-Length returns an error status, so out-of-tree clients must follow the exact upload contract.
📊 Macroscope summarized bc83013. 29 files reviewed, 6 issues evaluated, 1 issue filtered, 4 comments posted

🗂️ Filtered Issues

apps/server/src/http.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 401: The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL. [ Cross-file consolidated ]

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Sep 1, 2026
};
});

export const validateTranscriptionToken = Effect.fn("Transcription.validateToken")(function* (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Hightranscription/Transcription.ts:75

The same valid transcription URL can be POSTed repeatedly until expiresAt, causing each request to invoke OpenAI and potentially incur another charge. validateTranscriptionToken only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.

Also found in 1 other location(s)

apps/server/src/http.ts:401

The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/transcription/Transcription.ts around line 75:
The same valid transcription URL can be POSTed repeatedly until `expiresAt`, causing each request to invoke OpenAI and potentially incur another charge. `validateTranscriptionToken` only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.
Also found in 1 other location(s):
- apps/server/src/http.ts:401 -- The route validates only the stateless signature and expiry at `validateTranscriptionToken`; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

): Promise<AtomCommandResult<A, E>> {
const result = await settleAtomCommandResult(() => command.run(registry, input));
const result = await settleAtomCommandResult(() => command.run(registry, input, options.signal));
reportAtomCommandResult(result, { ...options, label: options.label ?? command.label }, reporter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumstate/runtime.ts:292

For singleFlight commands, aborting a second invocation does not return an interrupted result: runAtomCommand passes the signal into command.run, but createAtomCommandScheduler returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/runtime.ts around line 292:
For `singleFlight` commands, aborting a second invocation does not return an interrupted result: `runAtomCommand` passes the signal into `command.run`, but `createAtomCommandScheduler` returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

Comment threadapps/server/src/ws.ts
@@ -2420,11 +2428,13 @@ const makeWsRpcLayer = (
)
: Stream.empty;
const settingsUpdates = serverSettings.streamChanges.pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/ws.ts:2430

settingsUpdates is not subscribed until after loadServerConfig emits its snapshot, so a key change during discovery is missed and the client keeps a stale transcriptionServices catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/ws.ts around line 2430:
`settingsUpdates` is not subscribed until after `loadServerConfig` emits its snapshot, so a key change during discovery is missed and the client keeps a stale `transcriptionServices` catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

const operation = openAiApiKey.value.length > 0 ? "write-secret" : "remove-secret";
yield* (
openAiApiKey.value.length > 0
? secretStore.set(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/serverSettings.ts:669

A failed writeSettingsAtomically leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while settings.json still indicates a key is configured. persistProviderEnvironmentSecrets performs the secretStore.set/remove at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serverSettings.ts around line 669:
A failed `writeSettingsAtomically` leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while `settings.json` still indicates a key is configured. `persistProviderEnvironmentSecrets` performs the `secretStore.set`/`remove` at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

Comment on lines +147 to +152
const response = yield* httpClient
.post(OPENAI_TRANSCRIPTION_URL, {
headers: { Authorization: `Bearer ${apiKey}` },
body: HttpBody.formData(form),
})
.pipe(Effect.exit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect.exit swallows the request failure and the cause is never preserved or logged, so the returned 502 carries no diagnostic trail (and an interruption is reported as a request failure too). storeAttachmentUpload keeps the cause via Effect.logError(..., { cause }); consider doing the same here, and for the schemaBodyJson exit below (line 167) whose decode error is also discarded.

Suggested change
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(Effect.exit);
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(
Effect.tapError((cause)=>Effect.logError("OpenAI transcription request failed.",{ cause })),
Effect.exit,
);

Posted via Macroscope — Effect Service Conventions

) {
const [encoded, signature, unexpected] = token.split(".");
if (!encoded || !signature || unexpected) return null;
const secret = yield* loadSigningSecret.pipe(Effect.orElseSucceed(() => null));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The signing-key failure is dropped entirely here, so a broken secret store surfaces only as an opaque 404 with no cause anywhere in the logs. The sibling token validators (validateAttachmentUploadToken, AssetAccess) log the cause before falling back — consider matching them so the underlying failure is preserved.

Suggested change
constsecret=yield*loadSigningSecret.pipe(Effect.orElseSucceed(()=>null));
constsecret=yield*loadSigningSecret.pipe(
Effect.tapError((cause)=>
Effect.logError("Failed to load the transcription signing key.",{ cause }),
),
Effect.orElseSucceed(()=>null),
);

Posted via Macroscope — Effect Service Conventions

});
throwIfVoiceTranscriptionAborted(transcriptionSignal);
if (response.status < 200 || response.status >= 300) {
throw new Error(response.bodyText || `Transcription failed (${response.status}).`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This manufactures a bare Error purely so the catch below can wrap it as cause, and it puts the raw upstream response body into the error message. Throwing the domain error directly at the failure boundary keeps the structural code plus the safe HTTP status and drops the unbounded body text.

Suggested change
thrownewError(response.bodyText||`Transcription failed (${response.status}).`);
thrownewVoiceTranscriptionError(
"transcription-failed",
`The environment could not transcribe the recording (${response.status}).`,
);

Posted via Macroscope — Effect Service Conventions

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

const source =
selection.selectedSource ??
(selection.localTranscriber !== null ? "local" : selection.services[0]?.id);
if (source === "local" || source === undefined) return selection.localTranscriber;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale source blocks local transcription

Medium Severity

A persisted environment source is used even when that service is no longer advertised. isAvailable stays true whenever on-device transcription exists, so the mic remains shown, but getTranscriber still builds the environment transcriber and preparation fails. The settings row also disappears once services is empty, so there is no way to switch back to On this device.

Additional Locations (2)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

Comment on lines +2564 to +2577
<Input
aria-label="OpenAI transcription model"
className="w-full max-w-sm"
onBlur={() => {
const model = transcriptionModelDraft.trim();
if (model && model !== settings.transcription.model) {
updateSettings({ transcription: { model } });
} else {
setTranscriptionModelDraft(settings.transcription.model);
}
}}
onChange={(event) => setTranscriptionModelDraft(event.target.value)}
value={transcriptionModelDraft}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This row rebuilds the shared commit-on-blur field (DraftInput / useCommitOnBlur, already imported in this file) with a local draft plus a useEffect resync. The reconstruction drops Enter-to-commit, which every other server-backed settings text field supports, and the effect-based resync can overwrite an in-progress edit when the optimistic settings push lands, whereas useCommitOnBlur only resyncs while unfocused.

Suggested fix — use the primitive and delete transcriptionModelDraft's useState/useEffect at the top of GeneralSettingsPanel:

<DraftInputaria-label="OpenAI transcription model"className="w-full max-w-sm"value={settings.transcription.model}onCommit={(next)=>{constmodel=next.trim();if(model)updateSettings({transcription: { model }});}}/>

Posted via Macroscope — UI Consistency

Comment on lines +2523 to +2537
<div className="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event) => setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The trailing action is sized off the shared control scale used elsewhere: Input (default) is h-8.5 sm:h-7.5, while Button size="xs" is h-7 sm:h-6. Because the wrapper is flex without items-center, the shorter button stretches to flex-start and renders top-aligned and visibly smaller than the field. Every other input+action pair in settings matches sizes (default Input → default Button, compact → compact) and marks the action shrink-0 so the field absorbs the width.

Suggested change
<divclassName="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"
<divclassName="flex w-full max-w-sm items-center gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
className="shrink-0"
variant="outline"

Posted via Macroscope — UI Consistency

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces a substantial mobile-to-environment transcription workflow with new authenticated upload infrastructure, secret-backed OpenAI integration, and sensitive audio handling, while also changing the default transcription model. Unresolved findings include stale source selection and reusable upload URLs that can cause repeated provider calls and charges.

Not approved because:

  • 4 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

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

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Mobile voice dictation — mic button in the composer with BYOK OpenAI/Groq transcription

1 participant

@ahalekelly
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(mobile): environment-backed voice transcription - #9028

Open
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription
Open

feat(mobile): environment-backed voice transcription#9028
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription

Conversation

@ahalekelly

@ahalekellyahalekelly commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Generated with Claude Code

Problem

Mobile voice input currently requires Apple's on-device transcription, so it only exists on iOS 26+ iPhones — Android and older iOS have no mic button at all (#8718). docs/internals/voice-input.md (added in #8614) already specifies how environment-provided transcription should work, but it was unimplemented.

Change

Implements that spec as written:

  • Environment owns the credential. An OpenAI transcription API key is entered in web Settings → Voice input, persisted in the server's ServerSecretStore with its own explicit redaction — clients only ever see valueRedacted: true. Model defaults to gpt-transcribe, overridable in settings.
  • Capability-gated catalog. A transcription capability on ExecutionEnvironmentCapabilities, plus a derived transcriptionServices list (ids and labels only) on ServerConfig and the settingsUpdated payload. Older servers expose no remote choices; adding/removing the key updates availability live.
  • One-shot signed upload, nothing persisted. The client mints a short-lived signed URL over an authenticated RPC (mirroring the attachment-upload token pattern), POSTs the recording, and gets the transcript back in the same HTTP response. The server buffers in memory (25 MB cap, exact Content-Length enforcement) and forwards to OpenAI; audio is never written to disk, and aborts propagate to the upstream request.
  • Shared environment transcriber in packages/client-runtime implementing the existing VoiceTranscriber contract, with an injected platform transport (expo-file-system upload on mobile, matching the attachment transport split). Environment, service, and locale are captured when a recording starts; a disconnected environment reports unavailable rather than silently falling back.
  • Per-environment source picker on mobile (Settings → Environments): "On this device" vs the environment's service, stored per stable environmentId. Defaults to local when available, otherwise the environment service.
  • Android and iOS < 26 gain voice input automatically wherever an environment advertises transcription — the existing composer mic UI just becomes available.

Web/desktop voice capture remains out of scope, per the spec's boundaries; web only gains the settings section.

Relationship to #5213: that branch covers web/desktop BYOK dictation and has no apps/mobile code; this PR covers the mobile surface against the newer internals spec. Fixes#8718.

Verification

  • End-to-end on an iOS 26.5 simulator: spoken audio → recording → signed upload to the environment → OpenAI → transcript inserted in the composer ("T3 code voice transcription simulator verification."). Silence correctly surfaces the existing "No speech was detected." retry state.
  • Focused tests: server settings round-trip/redaction and transcription route (48), client-runtime environment transcriber and controller (68); targeted typecheck and lint on the five touched packages.

Screenshots

Mobile: per-environment pickerComposer micRecordingTranscript inserted

Web settings (environment key entry):

Implemented by GPT-5.6 Sol (Pi) with orchestration and code review by Claude Fable 5 (Claude Code).


Note

Medium Risk
Touches authenticated upload/transcription HTTP, secret persistence, and OpenAI forwarding with in-memory audio handling; mobile behavior changes when environments advertise transcription.

Overview
Adds environment-backed voice transcription so mobile can transcribe recordings via the connected server (OpenAI) when on-device speech is unavailable, not only on iOS 26+.

Server & contracts: New transcription capability, transcription.createUrl RPC, and a signed one-shot POST route that validates token/size, buffers audio in memory (no disk), and returns { text } from OpenAI. OpenAI API key and model live in server settings, stored in the secret store and redacted to clients; transcriptionServices is advertised on config and settingsUpdated events.

Shared client:createEnvironmentVoiceTranscriber mints URLs, uploads via injectable transport, and honors AbortSignal through atom commands. Server state exposes per-environment transcription service lists gated by capability.

Mobile: Composers pass environmentId into useVoiceInputController, which resolves local vs remote from persisted voiceTranscriptionSources. Settings → Environments adds a per-environment Voice transcription picker ("On this device" vs OpenAI). Mic availability becomes local or any advertised environment service.

Web: General settings gains Voice input (API key + model). Docs updated for mobile/environment flow.

Reviewed by Cursor Bugbot for commit bc83013. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add environment-backed voice transcription with OpenAI integration

  • Adds a signed-URL upload flow: clients call the new transcriptionCreateUrl WS RPC to get an expiring URL, upload audio, and the server forwards it to OpenAI, returning transcribed text. Token validation enforces exact Content-Length and rejects expired or malformed tokens.
  • Server settings now persist the OpenAI transcription API key in the secret store under TRANSCRIPTION_OPENAI_API_KEY_SECRET_NAME; the key is always redacted (value: "", valueRedacted: true) in settings responses. The web General settings panel exposes inputs for the API key and transcription model.
  • Mobile voice input controller accepts environmentId and selects between on-device transcription and environment-provided services based on user preferences and advertised transcriptionServices. A new per-environment row in the environments settings screen lets users pick the transcription source.
  • Client-runtime atom commands and queries now accept an AbortSignal via AtomCommandOptions, enabling cancellation-aware transcription flows in the environment transcriber.
  • Risk: the transcriptionCreateUrl RPC requires AuthOrchestrationOperateScope; callers without it will be rejected. The transcription POST endpoint at TRANSCRIPTION_ROUTE_PREFIX validates tokens server-side — any mismatch in signing key, expiry, or Content-Length returns an error status, so out-of-tree clients must follow the exact upload contract.
📊 Macroscope summarized bc83013. 29 files reviewed, 6 issues evaluated, 1 issue filtered, 4 comments posted

🗂️ Filtered Issues

apps/server/src/http.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 401: The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL. [ Cross-file consolidated ]

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Sep 1, 2026
};
});

export const validateTranscriptionToken = Effect.fn("Transcription.validateToken")(function* (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Hightranscription/Transcription.ts:75

The same valid transcription URL can be POSTed repeatedly until expiresAt, causing each request to invoke OpenAI and potentially incur another charge. validateTranscriptionToken only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.

Also found in 1 other location(s)

apps/server/src/http.ts:401

The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/transcription/Transcription.ts around line 75:
The same valid transcription URL can be POSTed repeatedly until `expiresAt`, causing each request to invoke OpenAI and potentially incur another charge. `validateTranscriptionToken` only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.
Also found in 1 other location(s):
- apps/server/src/http.ts:401 -- The route validates only the stateless signature and expiry at `validateTranscriptionToken`; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

): Promise<AtomCommandResult<A, E>> {
const result = await settleAtomCommandResult(() => command.run(registry, input));
const result = await settleAtomCommandResult(() => command.run(registry, input, options.signal));
reportAtomCommandResult(result, { ...options, label: options.label ?? command.label }, reporter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumstate/runtime.ts:292

For singleFlight commands, aborting a second invocation does not return an interrupted result: runAtomCommand passes the signal into command.run, but createAtomCommandScheduler returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/runtime.ts around line 292:
For `singleFlight` commands, aborting a second invocation does not return an interrupted result: `runAtomCommand` passes the signal into `command.run`, but `createAtomCommandScheduler` returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

Comment threadapps/server/src/ws.ts
@@ -2420,11 +2428,13 @@ const makeWsRpcLayer = (
)
: Stream.empty;
const settingsUpdates = serverSettings.streamChanges.pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/ws.ts:2430

settingsUpdates is not subscribed until after loadServerConfig emits its snapshot, so a key change during discovery is missed and the client keeps a stale transcriptionServices catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/ws.ts around line 2430:
`settingsUpdates` is not subscribed until after `loadServerConfig` emits its snapshot, so a key change during discovery is missed and the client keeps a stale `transcriptionServices` catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

const operation = openAiApiKey.value.length > 0 ? "write-secret" : "remove-secret";
yield* (
openAiApiKey.value.length > 0
? secretStore.set(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/serverSettings.ts:669

A failed writeSettingsAtomically leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while settings.json still indicates a key is configured. persistProviderEnvironmentSecrets performs the secretStore.set/remove at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serverSettings.ts around line 669:
A failed `writeSettingsAtomically` leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while `settings.json` still indicates a key is configured. `persistProviderEnvironmentSecrets` performs the `secretStore.set`/`remove` at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

Comment on lines +147 to +152
const response = yield* httpClient
.post(OPENAI_TRANSCRIPTION_URL, {
headers: { Authorization: `Bearer ${apiKey}` },
body: HttpBody.formData(form),
})
.pipe(Effect.exit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect.exit swallows the request failure and the cause is never preserved or logged, so the returned 502 carries no diagnostic trail (and an interruption is reported as a request failure too). storeAttachmentUpload keeps the cause via Effect.logError(..., { cause }); consider doing the same here, and for the schemaBodyJson exit below (line 167) whose decode error is also discarded.

Suggested change
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(Effect.exit);
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(
Effect.tapError((cause)=>Effect.logError("OpenAI transcription request failed.",{ cause })),
Effect.exit,
);

Posted via Macroscope — Effect Service Conventions

) {
const [encoded, signature, unexpected] = token.split(".");
if (!encoded || !signature || unexpected) return null;
const secret = yield* loadSigningSecret.pipe(Effect.orElseSucceed(() => null));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The signing-key failure is dropped entirely here, so a broken secret store surfaces only as an opaque 404 with no cause anywhere in the logs. The sibling token validators (validateAttachmentUploadToken, AssetAccess) log the cause before falling back — consider matching them so the underlying failure is preserved.

Suggested change
constsecret=yield*loadSigningSecret.pipe(Effect.orElseSucceed(()=>null));
constsecret=yield*loadSigningSecret.pipe(
Effect.tapError((cause)=>
Effect.logError("Failed to load the transcription signing key.",{ cause }),
),
Effect.orElseSucceed(()=>null),
);

Posted via Macroscope — Effect Service Conventions

});
throwIfVoiceTranscriptionAborted(transcriptionSignal);
if (response.status < 200 || response.status >= 300) {
throw new Error(response.bodyText || `Transcription failed (${response.status}).`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This manufactures a bare Error purely so the catch below can wrap it as cause, and it puts the raw upstream response body into the error message. Throwing the domain error directly at the failure boundary keeps the structural code plus the safe HTTP status and drops the unbounded body text.

Suggested change
thrownewError(response.bodyText||`Transcription failed (${response.status}).`);
thrownewVoiceTranscriptionError(
"transcription-failed",
`The environment could not transcribe the recording (${response.status}).`,
);

Posted via Macroscope — Effect Service Conventions

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

const source =
selection.selectedSource ??
(selection.localTranscriber !== null ? "local" : selection.services[0]?.id);
if (source === "local" || source === undefined) return selection.localTranscriber;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale source blocks local transcription

Medium Severity

A persisted environment source is used even when that service is no longer advertised. isAvailable stays true whenever on-device transcription exists, so the mic remains shown, but getTranscriber still builds the environment transcriber and preparation fails. The settings row also disappears once services is empty, so there is no way to switch back to On this device.

Additional Locations (2)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

Comment on lines +2564 to +2577
<Input
aria-label="OpenAI transcription model"
className="w-full max-w-sm"
onBlur={() => {
const model = transcriptionModelDraft.trim();
if (model && model !== settings.transcription.model) {
updateSettings({ transcription: { model } });
} else {
setTranscriptionModelDraft(settings.transcription.model);
}
}}
onChange={(event) => setTranscriptionModelDraft(event.target.value)}
value={transcriptionModelDraft}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This row rebuilds the shared commit-on-blur field (DraftInput / useCommitOnBlur, already imported in this file) with a local draft plus a useEffect resync. The reconstruction drops Enter-to-commit, which every other server-backed settings text field supports, and the effect-based resync can overwrite an in-progress edit when the optimistic settings push lands, whereas useCommitOnBlur only resyncs while unfocused.

Suggested fix — use the primitive and delete transcriptionModelDraft's useState/useEffect at the top of GeneralSettingsPanel:

<DraftInputaria-label="OpenAI transcription model"className="w-full max-w-sm"value={settings.transcription.model}onCommit={(next)=>{constmodel=next.trim();if(model)updateSettings({transcription: { model }});}}/>

Posted via Macroscope — UI Consistency

Comment on lines +2523 to +2537
<div className="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event) => setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The trailing action is sized off the shared control scale used elsewhere: Input (default) is h-8.5 sm:h-7.5, while Button size="xs" is h-7 sm:h-6. Because the wrapper is flex without items-center, the shorter button stretches to flex-start and renders top-aligned and visibly smaller than the field. Every other input+action pair in settings matches sizes (default Input → default Button, compact → compact) and marks the action shrink-0 so the field absorbs the width.

Suggested change
<divclassName="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"
<divclassName="flex w-full max-w-sm items-center gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
className="shrink-0"
variant="outline"

Posted via Macroscope — UI Consistency

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces a substantial mobile-to-environment transcription workflow with new authenticated upload infrastructure, secret-backed OpenAI integration, and sensitive audio handling, while also changing the default transcription model. Unresolved findings include stale source selection and reusable upload URLs that can cause repeated provider calls and charges.

Not approved because:

  • 4 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

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

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Mobile voice dictation — mic button in the composer with BYOK OpenAI/Groq transcription

1 participant

@ahalekelly
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(mobile): environment-backed voice transcription - #9028

Open
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription
Open

feat(mobile): environment-backed voice transcription#9028
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription

Conversation

@ahalekelly

@ahalekellyahalekelly commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Generated with Claude Code

Problem

Mobile voice input currently requires Apple's on-device transcription, so it only exists on iOS 26+ iPhones — Android and older iOS have no mic button at all (#8718). docs/internals/voice-input.md (added in #8614) already specifies how environment-provided transcription should work, but it was unimplemented.

Change

Implements that spec as written:

  • Environment owns the credential. An OpenAI transcription API key is entered in web Settings → Voice input, persisted in the server's ServerSecretStore with its own explicit redaction — clients only ever see valueRedacted: true. Model defaults to gpt-transcribe, overridable in settings.
  • Capability-gated catalog. A transcription capability on ExecutionEnvironmentCapabilities, plus a derived transcriptionServices list (ids and labels only) on ServerConfig and the settingsUpdated payload. Older servers expose no remote choices; adding/removing the key updates availability live.
  • One-shot signed upload, nothing persisted. The client mints a short-lived signed URL over an authenticated RPC (mirroring the attachment-upload token pattern), POSTs the recording, and gets the transcript back in the same HTTP response. The server buffers in memory (25 MB cap, exact Content-Length enforcement) and forwards to OpenAI; audio is never written to disk, and aborts propagate to the upstream request.
  • Shared environment transcriber in packages/client-runtime implementing the existing VoiceTranscriber contract, with an injected platform transport (expo-file-system upload on mobile, matching the attachment transport split). Environment, service, and locale are captured when a recording starts; a disconnected environment reports unavailable rather than silently falling back.
  • Per-environment source picker on mobile (Settings → Environments): "On this device" vs the environment's service, stored per stable environmentId. Defaults to local when available, otherwise the environment service.
  • Android and iOS < 26 gain voice input automatically wherever an environment advertises transcription — the existing composer mic UI just becomes available.

Web/desktop voice capture remains out of scope, per the spec's boundaries; web only gains the settings section.

Relationship to #5213: that branch covers web/desktop BYOK dictation and has no apps/mobile code; this PR covers the mobile surface against the newer internals spec. Fixes#8718.

Verification

  • End-to-end on an iOS 26.5 simulator: spoken audio → recording → signed upload to the environment → OpenAI → transcript inserted in the composer ("T3 code voice transcription simulator verification."). Silence correctly surfaces the existing "No speech was detected." retry state.
  • Focused tests: server settings round-trip/redaction and transcription route (48), client-runtime environment transcriber and controller (68); targeted typecheck and lint on the five touched packages.

Screenshots

Mobile: per-environment pickerComposer micRecordingTranscript inserted

Web settings (environment key entry):

Implemented by GPT-5.6 Sol (Pi) with orchestration and code review by Claude Fable 5 (Claude Code).


Note

Medium Risk
Touches authenticated upload/transcription HTTP, secret persistence, and OpenAI forwarding with in-memory audio handling; mobile behavior changes when environments advertise transcription.

Overview
Adds environment-backed voice transcription so mobile can transcribe recordings via the connected server (OpenAI) when on-device speech is unavailable, not only on iOS 26+.

Server & contracts: New transcription capability, transcription.createUrl RPC, and a signed one-shot POST route that validates token/size, buffers audio in memory (no disk), and returns { text } from OpenAI. OpenAI API key and model live in server settings, stored in the secret store and redacted to clients; transcriptionServices is advertised on config and settingsUpdated events.

Shared client:createEnvironmentVoiceTranscriber mints URLs, uploads via injectable transport, and honors AbortSignal through atom commands. Server state exposes per-environment transcription service lists gated by capability.

Mobile: Composers pass environmentId into useVoiceInputController, which resolves local vs remote from persisted voiceTranscriptionSources. Settings → Environments adds a per-environment Voice transcription picker ("On this device" vs OpenAI). Mic availability becomes local or any advertised environment service.

Web: General settings gains Voice input (API key + model). Docs updated for mobile/environment flow.

Reviewed by Cursor Bugbot for commit bc83013. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add environment-backed voice transcription with OpenAI integration

  • Adds a signed-URL upload flow: clients call the new transcriptionCreateUrl WS RPC to get an expiring URL, upload audio, and the server forwards it to OpenAI, returning transcribed text. Token validation enforces exact Content-Length and rejects expired or malformed tokens.
  • Server settings now persist the OpenAI transcription API key in the secret store under TRANSCRIPTION_OPENAI_API_KEY_SECRET_NAME; the key is always redacted (value: "", valueRedacted: true) in settings responses. The web General settings panel exposes inputs for the API key and transcription model.
  • Mobile voice input controller accepts environmentId and selects between on-device transcription and environment-provided services based on user preferences and advertised transcriptionServices. A new per-environment row in the environments settings screen lets users pick the transcription source.
  • Client-runtime atom commands and queries now accept an AbortSignal via AtomCommandOptions, enabling cancellation-aware transcription flows in the environment transcriber.
  • Risk: the transcriptionCreateUrl RPC requires AuthOrchestrationOperateScope; callers without it will be rejected. The transcription POST endpoint at TRANSCRIPTION_ROUTE_PREFIX validates tokens server-side — any mismatch in signing key, expiry, or Content-Length returns an error status, so out-of-tree clients must follow the exact upload contract.
📊 Macroscope summarized bc83013. 29 files reviewed, 6 issues evaluated, 1 issue filtered, 4 comments posted

🗂️ Filtered Issues

apps/server/src/http.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 401: The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL. [ Cross-file consolidated ]

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Sep 1, 2026
};
});

export const validateTranscriptionToken = Effect.fn("Transcription.validateToken")(function* (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Hightranscription/Transcription.ts:75

The same valid transcription URL can be POSTed repeatedly until expiresAt, causing each request to invoke OpenAI and potentially incur another charge. validateTranscriptionToken only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.

Also found in 1 other location(s)

apps/server/src/http.ts:401

The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/transcription/Transcription.ts around line 75:
The same valid transcription URL can be POSTed repeatedly until `expiresAt`, causing each request to invoke OpenAI and potentially incur another charge. `validateTranscriptionToken` only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.
Also found in 1 other location(s):
- apps/server/src/http.ts:401 -- The route validates only the stateless signature and expiry at `validateTranscriptionToken`; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

): Promise<AtomCommandResult<A, E>> {
const result = await settleAtomCommandResult(() => command.run(registry, input));
const result = await settleAtomCommandResult(() => command.run(registry, input, options.signal));
reportAtomCommandResult(result, { ...options, label: options.label ?? command.label }, reporter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumstate/runtime.ts:292

For singleFlight commands, aborting a second invocation does not return an interrupted result: runAtomCommand passes the signal into command.run, but createAtomCommandScheduler returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/runtime.ts around line 292:
For `singleFlight` commands, aborting a second invocation does not return an interrupted result: `runAtomCommand` passes the signal into `command.run`, but `createAtomCommandScheduler` returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

Comment threadapps/server/src/ws.ts
@@ -2420,11 +2428,13 @@ const makeWsRpcLayer = (
)
: Stream.empty;
const settingsUpdates = serverSettings.streamChanges.pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/ws.ts:2430

settingsUpdates is not subscribed until after loadServerConfig emits its snapshot, so a key change during discovery is missed and the client keeps a stale transcriptionServices catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/ws.ts around line 2430:
`settingsUpdates` is not subscribed until after `loadServerConfig` emits its snapshot, so a key change during discovery is missed and the client keeps a stale `transcriptionServices` catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

const operation = openAiApiKey.value.length > 0 ? "write-secret" : "remove-secret";
yield* (
openAiApiKey.value.length > 0
? secretStore.set(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/serverSettings.ts:669

A failed writeSettingsAtomically leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while settings.json still indicates a key is configured. persistProviderEnvironmentSecrets performs the secretStore.set/remove at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serverSettings.ts around line 669:
A failed `writeSettingsAtomically` leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while `settings.json` still indicates a key is configured. `persistProviderEnvironmentSecrets` performs the `secretStore.set`/`remove` at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

Comment on lines +147 to +152
const response = yield* httpClient
.post(OPENAI_TRANSCRIPTION_URL, {
headers: { Authorization: `Bearer ${apiKey}` },
body: HttpBody.formData(form),
})
.pipe(Effect.exit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect.exit swallows the request failure and the cause is never preserved or logged, so the returned 502 carries no diagnostic trail (and an interruption is reported as a request failure too). storeAttachmentUpload keeps the cause via Effect.logError(..., { cause }); consider doing the same here, and for the schemaBodyJson exit below (line 167) whose decode error is also discarded.

Suggested change
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(Effect.exit);
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(
Effect.tapError((cause)=>Effect.logError("OpenAI transcription request failed.",{ cause })),
Effect.exit,
);

Posted via Macroscope — Effect Service Conventions

) {
const [encoded, signature, unexpected] = token.split(".");
if (!encoded || !signature || unexpected) return null;
const secret = yield* loadSigningSecret.pipe(Effect.orElseSucceed(() => null));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The signing-key failure is dropped entirely here, so a broken secret store surfaces only as an opaque 404 with no cause anywhere in the logs. The sibling token validators (validateAttachmentUploadToken, AssetAccess) log the cause before falling back — consider matching them so the underlying failure is preserved.

Suggested change
constsecret=yield*loadSigningSecret.pipe(Effect.orElseSucceed(()=>null));
constsecret=yield*loadSigningSecret.pipe(
Effect.tapError((cause)=>
Effect.logError("Failed to load the transcription signing key.",{ cause }),
),
Effect.orElseSucceed(()=>null),
);

Posted via Macroscope — Effect Service Conventions

});
throwIfVoiceTranscriptionAborted(transcriptionSignal);
if (response.status < 200 || response.status >= 300) {
throw new Error(response.bodyText || `Transcription failed (${response.status}).`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This manufactures a bare Error purely so the catch below can wrap it as cause, and it puts the raw upstream response body into the error message. Throwing the domain error directly at the failure boundary keeps the structural code plus the safe HTTP status and drops the unbounded body text.

Suggested change
thrownewError(response.bodyText||`Transcription failed (${response.status}).`);
thrownewVoiceTranscriptionError(
"transcription-failed",
`The environment could not transcribe the recording (${response.status}).`,
);

Posted via Macroscope — Effect Service Conventions

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

const source =
selection.selectedSource ??
(selection.localTranscriber !== null ? "local" : selection.services[0]?.id);
if (source === "local" || source === undefined) return selection.localTranscriber;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale source blocks local transcription

Medium Severity

A persisted environment source is used even when that service is no longer advertised. isAvailable stays true whenever on-device transcription exists, so the mic remains shown, but getTranscriber still builds the environment transcriber and preparation fails. The settings row also disappears once services is empty, so there is no way to switch back to On this device.

Additional Locations (2)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

Comment on lines +2564 to +2577
<Input
aria-label="OpenAI transcription model"
className="w-full max-w-sm"
onBlur={() => {
const model = transcriptionModelDraft.trim();
if (model && model !== settings.transcription.model) {
updateSettings({ transcription: { model } });
} else {
setTranscriptionModelDraft(settings.transcription.model);
}
}}
onChange={(event) => setTranscriptionModelDraft(event.target.value)}
value={transcriptionModelDraft}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This row rebuilds the shared commit-on-blur field (DraftInput / useCommitOnBlur, already imported in this file) with a local draft plus a useEffect resync. The reconstruction drops Enter-to-commit, which every other server-backed settings text field supports, and the effect-based resync can overwrite an in-progress edit when the optimistic settings push lands, whereas useCommitOnBlur only resyncs while unfocused.

Suggested fix — use the primitive and delete transcriptionModelDraft's useState/useEffect at the top of GeneralSettingsPanel:

<DraftInputaria-label="OpenAI transcription model"className="w-full max-w-sm"value={settings.transcription.model}onCommit={(next)=>{constmodel=next.trim();if(model)updateSettings({transcription: { model }});}}/>

Posted via Macroscope — UI Consistency

Comment on lines +2523 to +2537
<div className="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event) => setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The trailing action is sized off the shared control scale used elsewhere: Input (default) is h-8.5 sm:h-7.5, while Button size="xs" is h-7 sm:h-6. Because the wrapper is flex without items-center, the shorter button stretches to flex-start and renders top-aligned and visibly smaller than the field. Every other input+action pair in settings matches sizes (default Input → default Button, compact → compact) and marks the action shrink-0 so the field absorbs the width.

Suggested change
<divclassName="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"
<divclassName="flex w-full max-w-sm items-center gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
className="shrink-0"
variant="outline"

Posted via Macroscope — UI Consistency

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces a substantial mobile-to-environment transcription workflow with new authenticated upload infrastructure, secret-backed OpenAI integration, and sensitive audio handling, while also changing the default transcription model. Unresolved findings include stale source selection and reusable upload URLs that can cause repeated provider calls and charges.

Not approved because:

  • 4 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

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

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Mobile voice dictation — mic button in the composer with BYOK OpenAI/Groq transcription

1 participant

@ahalekelly
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(mobile): environment-backed voice transcription - #9028

Open
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription
Open

feat(mobile): environment-backed voice transcription#9028
ahalekelly wants to merge 5 commits into
pingdotgg:mainfrom
ahalekelly:feat/environment-voice-transcription

Conversation

@ahalekelly

@ahalekellyahalekelly commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Generated with Claude Code

Problem

Mobile voice input currently requires Apple's on-device transcription, so it only exists on iOS 26+ iPhones — Android and older iOS have no mic button at all (#8718). docs/internals/voice-input.md (added in #8614) already specifies how environment-provided transcription should work, but it was unimplemented.

Change

Implements that spec as written:

  • Environment owns the credential. An OpenAI transcription API key is entered in web Settings → Voice input, persisted in the server's ServerSecretStore with its own explicit redaction — clients only ever see valueRedacted: true. Model defaults to gpt-transcribe, overridable in settings.
  • Capability-gated catalog. A transcription capability on ExecutionEnvironmentCapabilities, plus a derived transcriptionServices list (ids and labels only) on ServerConfig and the settingsUpdated payload. Older servers expose no remote choices; adding/removing the key updates availability live.
  • One-shot signed upload, nothing persisted. The client mints a short-lived signed URL over an authenticated RPC (mirroring the attachment-upload token pattern), POSTs the recording, and gets the transcript back in the same HTTP response. The server buffers in memory (25 MB cap, exact Content-Length enforcement) and forwards to OpenAI; audio is never written to disk, and aborts propagate to the upstream request.
  • Shared environment transcriber in packages/client-runtime implementing the existing VoiceTranscriber contract, with an injected platform transport (expo-file-system upload on mobile, matching the attachment transport split). Environment, service, and locale are captured when a recording starts; a disconnected environment reports unavailable rather than silently falling back.
  • Per-environment source picker on mobile (Settings → Environments): "On this device" vs the environment's service, stored per stable environmentId. Defaults to local when available, otherwise the environment service.
  • Android and iOS < 26 gain voice input automatically wherever an environment advertises transcription — the existing composer mic UI just becomes available.

Web/desktop voice capture remains out of scope, per the spec's boundaries; web only gains the settings section.

Relationship to #5213: that branch covers web/desktop BYOK dictation and has no apps/mobile code; this PR covers the mobile surface against the newer internals spec. Fixes#8718.

Verification

  • End-to-end on an iOS 26.5 simulator: spoken audio → recording → signed upload to the environment → OpenAI → transcript inserted in the composer ("T3 code voice transcription simulator verification."). Silence correctly surfaces the existing "No speech was detected." retry state.
  • Focused tests: server settings round-trip/redaction and transcription route (48), client-runtime environment transcriber and controller (68); targeted typecheck and lint on the five touched packages.

Screenshots

Mobile: per-environment pickerComposer micRecordingTranscript inserted

Web settings (environment key entry):

Implemented by GPT-5.6 Sol (Pi) with orchestration and code review by Claude Fable 5 (Claude Code).


Note

Medium Risk
Touches authenticated upload/transcription HTTP, secret persistence, and OpenAI forwarding with in-memory audio handling; mobile behavior changes when environments advertise transcription.

Overview
Adds environment-backed voice transcription so mobile can transcribe recordings via the connected server (OpenAI) when on-device speech is unavailable, not only on iOS 26+.

Server & contracts: New transcription capability, transcription.createUrl RPC, and a signed one-shot POST route that validates token/size, buffers audio in memory (no disk), and returns { text } from OpenAI. OpenAI API key and model live in server settings, stored in the secret store and redacted to clients; transcriptionServices is advertised on config and settingsUpdated events.

Shared client:createEnvironmentVoiceTranscriber mints URLs, uploads via injectable transport, and honors AbortSignal through atom commands. Server state exposes per-environment transcription service lists gated by capability.

Mobile: Composers pass environmentId into useVoiceInputController, which resolves local vs remote from persisted voiceTranscriptionSources. Settings → Environments adds a per-environment Voice transcription picker ("On this device" vs OpenAI). Mic availability becomes local or any advertised environment service.

Web: General settings gains Voice input (API key + model). Docs updated for mobile/environment flow.

Reviewed by Cursor Bugbot for commit bc83013. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add environment-backed voice transcription with OpenAI integration

  • Adds a signed-URL upload flow: clients call the new transcriptionCreateUrl WS RPC to get an expiring URL, upload audio, and the server forwards it to OpenAI, returning transcribed text. Token validation enforces exact Content-Length and rejects expired or malformed tokens.
  • Server settings now persist the OpenAI transcription API key in the secret store under TRANSCRIPTION_OPENAI_API_KEY_SECRET_NAME; the key is always redacted (value: "", valueRedacted: true) in settings responses. The web General settings panel exposes inputs for the API key and transcription model.
  • Mobile voice input controller accepts environmentId and selects between on-device transcription and environment-provided services based on user preferences and advertised transcriptionServices. A new per-environment row in the environments settings screen lets users pick the transcription source.
  • Client-runtime atom commands and queries now accept an AbortSignal via AtomCommandOptions, enabling cancellation-aware transcription flows in the environment transcriber.
  • Risk: the transcriptionCreateUrl RPC requires AuthOrchestrationOperateScope; callers without it will be rejected. The transcription POST endpoint at TRANSCRIPTION_ROUTE_PREFIX validates tokens server-side — any mismatch in signing key, expiry, or Content-Length returns an error status, so out-of-tree clients must follow the exact upload contract.
📊 Macroscope summarized bc83013. 29 files reviewed, 6 issues evaluated, 1 issue filtered, 4 comments posted

🗂️ Filtered Issues

apps/server/src/http.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 401: The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL. [ Cross-file consolidated ]

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Sep 1, 2026
};
});

export const validateTranscriptionToken = Effect.fn("Transcription.validateToken")(function* (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Hightranscription/Transcription.ts:75

The same valid transcription URL can be POSTed repeatedly until expiresAt, causing each request to invoke OpenAI and potentially incur another charge. validateTranscriptionToken only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.

Also found in 1 other location(s)

apps/server/src/http.ts:401

The route validates only the stateless signature and expiry at validateTranscriptionToken; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/transcription/Transcription.ts around line 75:
The same valid transcription URL can be POSTed repeatedly until `expiresAt`, causing each request to invoke OpenAI and potentially incur another charge. `validateTranscriptionToken` only checks the stateless signature and expiry; it never records or consumes the token. Add one-shot consumption state (and reject already-consumed tokens) before forwarding the upload to OpenAI.
Also found in 1 other location(s):
- apps/server/src/http.ts:401 -- The route validates only the stateless signature and expiry at `validateTranscriptionToken`; it never records or consumes a token. A caller can POST the same minted URL repeatedly until it expires, and every request passes line 401 and invokes OpenAI again. This violates the intended single-upload authorization and allows repeated transcription/billing with one RPC-minted URL.

): Promise<AtomCommandResult<A, E>> {
const result = await settleAtomCommandResult(() => command.run(registry, input));
const result = await settleAtomCommandResult(() => command.run(registry, input, options.signal));
reportAtomCommandResult(result, { ...options, label: options.label ?? command.label }, reporter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumstate/runtime.ts:292

For singleFlight commands, aborting a second invocation does not return an interrupted result: runAtomCommand passes the signal into command.run, but createAtomCommandScheduler returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/runtime.ts around line 292:
For `singleFlight` commands, aborting a second invocation does not return an interrupted result: `runAtomCommand` passes the signal into `command.run`, but `createAtomCommandScheduler` returns the existing promise before observing that caller's signal. The second caller therefore remains blocked until the first invocation finishes; add per-caller abort handling around the shared promise.

Comment threadapps/server/src/ws.ts
@@ -2420,11 +2428,13 @@ const makeWsRpcLayer = (
)
: Stream.empty;
const settingsUpdates = serverSettings.streamChanges.pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/ws.ts:2430

settingsUpdates is not subscribed until after loadServerConfig emits its snapshot, so a key change during discovery is missed and the client keeps a stale transcriptionServices catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/ws.ts around line 2430:
`settingsUpdates` is not subscribed until after `loadServerConfig` emits its snapshot, so a key change during discovery is missed and the client keeps a stale `transcriptionServices` catalog until another change or reconnect. Acquire the settings subscription before loading/emitting the snapshot, then consume that subscribed stream for live updates.

const operation = openAiApiKey.value.length > 0 ? "write-secret" : "remove-secret";
yield* (
openAiApiKey.value.length > 0
? secretStore.set(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/serverSettings.ts:669

A failed writeSettingsAtomically leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while settings.json still indicates a key is configured. persistProviderEnvironmentSecrets performs the secretStore.set/remove at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serverSettings.ts around line 669:
A failed `writeSettingsAtomically` leaves the OpenAI secret store changed even though the settings update returns an error, so key rotation can make the running server use an uncommitted key and clearing can disable transcription while `settings.json` still indicates a key is configured. `persistProviderEnvironmentSecrets` performs the `secretStore.set`/`remove` at lines 669–673 before the file commit at line 811; make the secret update and settings-file update transactional (or restore the previous secret when the file write fails).

Comment on lines +147 to +152
const response = yield* httpClient
.post(OPENAI_TRANSCRIPTION_URL, {
headers: { Authorization: `Bearer ${apiKey}` },
body: HttpBody.formData(form),
})
.pipe(Effect.exit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect.exit swallows the request failure and the cause is never preserved or logged, so the returned 502 carries no diagnostic trail (and an interruption is reported as a request failure too). storeAttachmentUpload keeps the cause via Effect.logError(..., { cause }); consider doing the same here, and for the schemaBodyJson exit below (line 167) whose decode error is also discarded.

Suggested change
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(Effect.exit);
constresponse=yield*httpClient
.post(OPENAI_TRANSCRIPTION_URL,{
headers: {Authorization: `Bearer ${apiKey}`},
body: HttpBody.formData(form),
})
.pipe(
Effect.tapError((cause)=>Effect.logError("OpenAI transcription request failed.",{ cause })),
Effect.exit,
);

Posted via Macroscope — Effect Service Conventions

) {
const [encoded, signature, unexpected] = token.split(".");
if (!encoded || !signature || unexpected) return null;
const secret = yield* loadSigningSecret.pipe(Effect.orElseSucceed(() => null));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The signing-key failure is dropped entirely here, so a broken secret store surfaces only as an opaque 404 with no cause anywhere in the logs. The sibling token validators (validateAttachmentUploadToken, AssetAccess) log the cause before falling back — consider matching them so the underlying failure is preserved.

Suggested change
constsecret=yield*loadSigningSecret.pipe(Effect.orElseSucceed(()=>null));
constsecret=yield*loadSigningSecret.pipe(
Effect.tapError((cause)=>
Effect.logError("Failed to load the transcription signing key.",{ cause }),
),
Effect.orElseSucceed(()=>null),
);

Posted via Macroscope — Effect Service Conventions

});
throwIfVoiceTranscriptionAborted(transcriptionSignal);
if (response.status < 200 || response.status >= 300) {
throw new Error(response.bodyText || `Transcription failed (${response.status}).`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This manufactures a bare Error purely so the catch below can wrap it as cause, and it puts the raw upstream response body into the error message. Throwing the domain error directly at the failure boundary keeps the structural code plus the safe HTTP status and drops the unbounded body text.

Suggested change
thrownewError(response.bodyText||`Transcription failed (${response.status}).`);
thrownewVoiceTranscriptionError(
"transcription-failed",
`The environment could not transcribe the recording (${response.status}).`,
);

Posted via Macroscope — Effect Service Conventions

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

const source =
selection.selectedSource ??
(selection.localTranscriber !== null ? "local" : selection.services[0]?.id);
if (source === "local" || source === undefined) return selection.localTranscriber;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale source blocks local transcription

Medium Severity

A persisted environment source is used even when that service is no longer advertised. isAvailable stays true whenever on-device transcription exists, so the mic remains shown, but getTranscriber still builds the environment transcriber and preparation fails. The settings row also disappears once services is empty, so there is no way to switch back to On this device.

Additional Locations (2)
Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit bc83013. Configure here.

Comment on lines +2564 to +2577
<Input
aria-label="OpenAI transcription model"
className="w-full max-w-sm"
onBlur={() => {
const model = transcriptionModelDraft.trim();
if (model && model !== settings.transcription.model) {
updateSettings({ transcription: { model } });
} else {
setTranscriptionModelDraft(settings.transcription.model);
}
}}
onChange={(event) => setTranscriptionModelDraft(event.target.value)}
value={transcriptionModelDraft}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This row rebuilds the shared commit-on-blur field (DraftInput / useCommitOnBlur, already imported in this file) with a local draft plus a useEffect resync. The reconstruction drops Enter-to-commit, which every other server-backed settings text field supports, and the effect-based resync can overwrite an in-progress edit when the optimistic settings push lands, whereas useCommitOnBlur only resyncs while unfocused.

Suggested fix — use the primitive and delete transcriptionModelDraft's useState/useEffect at the top of GeneralSettingsPanel:

<DraftInputaria-label="OpenAI transcription model"className="w-full max-w-sm"value={settings.transcription.model}onCommit={(next)=>{constmodel=next.trim();if(model)updateSettings({transcription: { model }});}}/>

Posted via Macroscope — UI Consistency

Comment on lines +2523 to +2537
<div className="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event) => setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The trailing action is sized off the shared control scale used elsewhere: Input (default) is h-8.5 sm:h-7.5, while Button size="xs" is h-7 sm:h-6. Because the wrapper is flex without items-center, the shorter button stretches to flex-start and renders top-aligned and visibly smaller than the field. Every other input+action pair in settings matches sizes (default Input → default Button, compact → compact) and marks the action shrink-0 so the field absorbs the width.

Suggested change
<divclassName="flex w-full max-w-sm gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
size="xs"
variant="outline"
<divclassName="flex w-full max-w-sm items-center gap-2">
<Input
aria-label="OpenAI transcription API key"
autoComplete="off"
className="min-w-0 flex-1"
onChange={(event)=>setTranscriptionKeyDraft(event.target.value)}
placeholder={
settings.transcription.openAiApiKey.valueRedacted ? "Key configured" : "sk-..."
}
type="password"
value={transcriptionKeyDraft}
/>
<Button
className="shrink-0"
variant="outline"

Posted via Macroscope — UI Consistency

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces a substantial mobile-to-environment transcription workflow with new authenticated upload infrastructure, secret-backed OpenAI integration, and sensitive audio handling, while also changing the default transcription model. Unresolved findings include stale source selection and reusable upload URLs that can cause repeated provider calls and charges.

Not approved because:

  • 4 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

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

Labels

size:XL500-999 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Mobile voice dictation — mic button in the composer with BYOK OpenAI/Groq transcription

1 participant

@ahalekelly