Wire shake-to-narrate into your app.
This is the working setup as it ships today: a self-hosted collector worker, the AppFeedback iOS SDK, and a Mac-side CLI you point a coding agent's /loop at. Built for the development phase - stand up the loop, watch beta testers narrate what's broken, feed it to your agents as backlog.
iOS SDK setup
Add the AppFeedback Swift package, attach the recorder at your SwiftUI root, and resolve its config from a gitignored xcconfig so the recorder is silently absent unless a collector URL and upload secret ship in the build. Debug turns it on locally; Release stays gated at runtime by a remote flag plus a Settings opt-in.
- 01Add AppFeedback via Swift Package Manager (SPM) to your app target.
- 02Attach the recorder once at the SwiftUI root with .feedbackRecorder(config:) - above your shell root so a shake records during onboarding too.
- 03Resolve the config from Info.plist keys fed by build settings; return nil when unconfigured so the recorder never links live by accident.
import AppFeedback
import SwiftUI
// Pure resolver: nil unless both values are non-empty, non-placeholder, and
// the URL is real http(s) with a host. Mirror this per app.
enum MyAppFeedback {
static let appId = "myapp"
static func config(bundle: Bundle = .main) -> FeedbackConfig? {
let url = (bundle.object(forInfoDictionaryKey: "FEEDBACK_COLLECTOR_URL") as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let secret = (bundle.object(forInfoDictionaryKey: "FEEDBACK_SECRET") as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !url.isEmpty, !secret.isEmpty,
!url.hasPrefix("$("), !secret.hasPrefix("$("),
let collectorURL = URL(string: url),
let scheme = collectorURL.scheme?.lowercased(),
scheme == "http" || scheme == "https",
let host = collectorURL.host, !host.isEmpty
else { return nil }
return FeedbackConfig(app: appId, collectorURL: collectorURL, secret: secret)
}
}
// At the root view:
struct RootView: View {
var body: some View {
AppShell()
.feedbackRecorder(config: MyAppFeedback.config())
}
}FEEDBACK_SECRET in the build is the WRITE-ONLY upload secret (FEEDBACK_UPLOAD_SECRET_<APP>, POST /presign only). The read/review secret is server-only and must NEVER go in a binary.
- 01Debug: create a gitignored feedback.local.xcconfig from the checked-in .example. Empty or missing values = recorder absent.
- 02Release: the same xcconfig is referenced, but activation is gated at runtime (remote flag AND Settings opt-in), so nothing records until both are true.
// feedback.local.xcconfig (gitignored; copy from feedback.local.xcconfig.example)
// xcconfig treats // as a comment even inside a value - $() splits the token
// so the URL survives. Do not "fix" it.
FEEDBACK_COLLECTOR_URL = https:/$()/feedback-collector.example.workers.dev
FEEDBACK_SECRET = <paste FEEDBACK_UPLOAD_SECRET_MYAPP>Backend: collector worker
The feedback-collector is a Cloudflare Worker that owns an R2 bucket. Apps POST /presign to get short-lived HMAC-signed PUT /upload URLs; the /review page and Mac processor read sessions back. Deploy is manual and intentionally not in CI. Each app gets a pair of secrets, and the runtime activation flag is seeded through the sdui-experiments admin route.
- 01Deploy the worker manually from packages/feedback-collector (wrangler deploy). It is intentionally not wired into CI.
- 02Set the signing secret and one secret PAIR per app before the first deploy.
- 03Seed the runtime activation flag DISABLED, then flip it on when you are ready for Release captures.
# One-time, per worker: HMAC signing secret for upload/download tokens.
wrangler secret put UPLOAD_SIGNING_SECRET
# Per app, a PAIR. Suffix = app id uppercased, dashes -> underscores.
# server-only: review page, session listing, downloads, Mac presigns
wrangler secret put FEEDBACK_SECRET_MYAPP
# write-only: embedded in app builds, accepted ONLY for capture presigns
wrangler secret put FEEDBACK_UPLOAD_SECRET_MYAPPThe upload scope can only presign the capture artifacts (recording.mov and events.json), is capped at 100 successful presigns per app per UTC day, and every signed PUT is capped at 200 MiB. It is an API key to your worker - not a Cloudflare or R2 credential - so host apps never need their own backend.
# Seed the runtime flag DISABLED via the sdui-experiments admin route
# (Bearer SDUI_ADMIN_TOKEN). Release recording only activates when this flag
# AND the in-app Settings opt-in are both true.
curl -X PUT "https://sdui.myapp.example/admin/flags/myapp/feedbackEnabled" \
-H "Authorization: Bearer $SDUI_ADMIN_TOKEN" \
-H "content-type: application/json" \
-d '{"enabled": false, "rolloutPercent": 0}'
# Flip enabled:true (and raise rolloutPercent) when ready to record in Release.CI: build with the secret
Release archives need the collector URL and the write-only upload secret compiled into the xcconfig. In Xcode Cloud you set two workflow environment variables and a ci_post_clone.sh writes the gitignored xcconfig from them. No Xcode Cloud? The same file is written by hand for a local archive lane.
- 01In Xcode Cloud, add two environment variables to the workflow: FEEDBACK_COLLECTOR_URL and FEEDBACK_UPLOAD_SECRET_MYAPP.
- 02ci_post_clone.sh writes feedback.local.xcconfig from those env vars before build; with either absent it fails closed (truncates the file) so a stale secret can never survive.
- 03Local archive lane: write feedback.local.xcconfig yourself (same two values) and archive as usual.
# ios/ci_scripts/ci_post_clone.sh - Xcode Cloud runs this after clone.
XCCONFIG="$(dirname "$0")/../feedback.local.xcconfig"
if [ -n "${FEEDBACK_COLLECTOR_URL:-}" ] && [ -n "${FEEDBACK_UPLOAD_SECRET_MYAPP:-}" ]; then
# xcconfig treats // as a comment even inside a value; $() splits the token.
url_escaped=$(printf '%s' "$FEEDBACK_COLLECTOR_URL" | sed 's#://#:/$()/#')
{
printf 'FEEDBACK_COLLECTOR_URL = %s\n' "$url_escaped"
printf 'FEEDBACK_SECRET = %s\n' "$FEEDBACK_UPLOAD_SECRET_MYAPP"
} > "$XCCONFIG"
else
# Fail closed: truncate so a stale URL/secret cannot leak into this build.
: > "$XCCONFIG"
fiOnly the write-only upload secret is ever compiled into a binary. The server-only FEEDBACK_SECRET_MYAPP stays in the worker and never touches an app build or a CI env that reaches the binary.
Processing: point an agent's /loop at the queue
A shake becomes a screen recording plus mic narration plus a tap/screen trail. The Mac-side CLI downloads a session, transcribes it, extracts keyframes, asks Claude for structured findings, and opens deduped GitHub issues labelled feedback. That is the wedge: run it on a standing /loop so beta-tester narration lands in your agents' backlog automatically.
- 01Install the processor's tools on PATH: ffmpeg, whisper.cpp (whisper-cli), the Claude CLI, and an authenticated GitHub CLI. First run downloads the whisper base model.
- 02Export FEEDBACK_COLLECTOR_URL and the server-only FEEDBACK_SECRET, then process one session or drain the queue.
- 03Wrap the --all drain in a coding agent's /loop so new narrated sessions are pulled, triaged into issues, and worked as they arrive.
export FEEDBACK_COLLECTOR_URL="https://feedback-collector.example.workers.dev"
export FEEDBACK_SECRET="<server-only FEEDBACK_SECRET_MYAPP>"
# One session
bun packages/feedback-collector/cli/process.ts --session session-1234
# Drain the queue for an app (only sessions with hasAnalysis: false)
bun packages/feedback-collector/cli/process.ts --all --app myapp
# See the analysis + would-be issues without uploading or filing anything
bun packages/feedback-collector/cli/process.ts --session session-1234 --dry-runEach finding becomes a gh issue in your repo, label feedback, title [feedback][<app>][<screen>] <title>, deduped against open titles. Point a coding agent at that /loop - pending sessions in, filed-and-fixed issues out - and beta feedback becomes agent backlog.
# The standing loop: a coding agent pulls the queue on an interval, triages
# into issues, and works them - the dev-phase motion this is built for.
/loop 10m bun packages/feedback-collector/cli/process.ts --all --app myapp