Case study

Cira: designing trust into a dictation app

A macOS menubar dictation app I took from blank repo to a paid, live backend in five weeks, where most of the hard problems turned out to be about trust, not transcription.

Solo founder · May 2026 to now · macOS 14+, SwiftUI, Supabase, Stripe · In daily personal use

C

Hotkey

01

Recording

02

Transcribing

Cleaning up

Learning

03·04

Inserting

05

One dictation, step by step through Cira’s menubar. The numbered steps are where trust broke; each challenge below picks up one of them.

Hero video: one dictation, from the hotkey to text landing in Slack

At a glance

Role

Solo founder: product, UX, brand, PRDs. Designed and built with AI coding tools, me directing and reviewing every decision.

Team

Me, plus separate AI agents for app, backend, admin, website and marketing, coordinated through a written sync log I owned.

Timeline

First commit 30 May 2026. Core code through 7 July. Landing page live 1 September. In daily personal use, with a UI redesign next.

Platform and stack

macOS 14+, Swift / SwiftUI / AppKit (SPM). Deno on Supabase Edge Functions and Postgres. Stripe. Static web, vanilla-JS admin.

Status

Dictation, billing, trial and Memory v1/v2 are live in production. Memory v2.5 is finished on dev but held for legal review.

Not publicly launched yet. I use it every day; the next step is a UI redesign.

The friction wasn’t accuracy. It was uncertainty.

Typing is slower than talking, but the good dictation tools either wanted your own API key or cost around $15 a month, and none handled Persian well. Using them, I found the friction wasn’t mainly accuracy.

Is it listening, what is it waiting on, where will the text land?

Cira was my attempt to design that uncertainty out, and to build it for real to see if the design held up.

739

commits across six repos, from blank repo to a paid, live backend

739

commits across six repos, from blank repo to a paid, live backend

2.5 wks

from first commit to live dictation, trial and Stripe billing

2.5 wks

from first commit to live dictation, trial and Stripe billing

$8/mo

for Pro after an 8-minute free trial, with no API key to set up

$8/mo

for Pro after an 8-minute free trial, with no API key to set up

PRD snapshot

One north-star metric, three non-goals

Users

English-speaking professionals who write faster by voice: the main market. Persian speakers are a differentiator, not the design driver.

Goals

No setup and no API key. Text pasted into whatever app you’re in. It should learn how you write over time.

Constraints

Solo, no budget for paid tooling, and a privacy promise (“your text isn’t stored”) that every later feature had to keep.

Non-goals

×

iOS

Assessed, not started.

×

A “Hey Cira” wake word

Researched, then deprioritised.

×

Speed or accuracy claims without a benchmark

A written brand rule.

North-star metric · post-paste edit rate

What you rewrite by hand

What Cira pasted

↓

Lower means closer to your voice

Computed on the Mac, so no text leaves the device to measure it.

Business model · Cira Pro

Setup

No API key

Free trial

8 min

Pro, monthly

$8

Raised from $6 when Memory v2 shipped, to cover its cloud cost.

Source: board, ToS draft, marketing sync.

PRD scoring table from the audio-ducking PRD: appeal, usefulness, difficulty (translated)

Every feature got a PRD and a scoring table before any code.

Challenge 01 of 05 · Hotkey

The anti-abuse fix that would have broken every user

Situation

A free trial with a device key generated on the client can be farmed: a script can create unlimited devices and get unlimited trials.

Why it was hard

The textbook fix is Apple’s App Attest. We built it end to end, client and server, with a hybrid “attest or fall back” trial.

What happened

Testing showed DCAppAttestService.isSupported is always false on native macOS: across Developer ID, provisioning and Full Security, every Mac failed. It’s an iOS-only API.

Shipping it would have silently cut every Mac user’s trial from 8 minutes to 2.

App Attest check · native macOS

Developer ID

→ false

Provisioning

→ false

Full Security

→ false

8:00

Trial as designed

2:00

Every Mac, had App Attest shipped

It’s an iOS-only API. Every Mac would have failed the check and fallen back to the 2-minute trial, silently.

The decision

Shipped

Atomic rate limits in Postgres

Per device, per account and per IP, so a script can’t farm trials.

Parked

App Attest, end to end

Kept off production, preserved on a branch for a future iOS app.

Outcome

Trial abuse was covered for a soft launch with no regression. The lesson I kept: test the platform’s assumptions before trusting a well-known pattern.

Challenge 02 of 05 · Recording

Bluetooth headphones made music sound like a phone call

Situation

Dictating with AirPods dropped the music to call quality, and it stayed there afterwards.

Why it was hard

Opening a Bluetooth mic forces the headset from its high-quality stereo profile into a mono call profile, and macOS is slow to switch back. Users blamed Cira, and they were right to.

Options

Tell users to change their input

Pushes the problem onto them.

Keep the default mic

The bug stays.

Choose the mic per recording

Chosen. Checked on every recording start.

The decision

Check the default input on every recording start and route around Bluetooth. I ranked this P0, ahead of the ducking feature it unblocked.

Input · at every recording start

AirPods

Bluetooth default input. Skipped, so the headset never drops to its mono call profile.

✓

Built-in microphone

Cira records here. Music stays in stereo, in high quality.

Choose input manually…

A manual picker overrides the routing.

Ducking: music lowering while dictating

Settings → Audio

Ducking shipped with three choices: off, lower, or pause and resume.

Outcome

Music stays in high quality. Ducking then shipped with three choices (off, lower, pause and resume), and volume is restored on every exit path.

Challenge 03 of 05 · Learning

“Never stored” vs. “this leaves your device”

Situation

Memory v2 (“Learn from me”) builds a style card in the cloud from your corrections. That clashed directly with the privacy promise on the homepage.

Why it was hard

A UX review produced 15 ranked problems. The top three are below.

UX review · 15 ranked problems

01

The two messages sat side by side and read like a caught lie.

02

Two switches had labels that were the reverse of their actual risk.

03

“Accept all” consent trained users to tap yes blindly.

04–15

Twelve more, ranked below these.

Open design questions

26 of 26 answered

The decision

I answered all 26 open design questions, starting with the claim itself.

Split About-you into three tiers: see it, steer it, control my data. Made redaction fail-closed: if the redactor isn’t sure, the sample isn’t learned from.

The claim, before and after

Before · homepage

“Never stored.”

Before · Learn from me

“This leaves your device.”

After · both screens

“Never stored on our servers.”

Vendor named inline. One switch at the moment of value; the other moved to Settings.

Memory “About you” screen in three tiers: see it, steer it, control my data

About-you, split by how much control each part needs.

Release gates

Memory v2 · cloud learning

Live

Built

Data agreement

Legal review

Production

Memory v2.5

Held

Built

Data agreement

Legal review

Production

Outcome

Cloud learning went live only after a data-processing agreement and a legal review. Memory v2.5 is finished but held off production until it clears the same gates.

Challenge 04 of 05 · Learning

Choosing the model with evidence, not vibes

Evidence

To pick the model that builds the style cards, we ran a blind A/B on 9 synthetic personas with two independent judges. The judges liked Qwen. The numbers around them didn’t.

Blind A/B · 9 personas · 2 judges

13 of 18*

comparisons went to Qwen

Time to a Persian style card · seconds

Qwen

Qwen

12–18 s

gpt-oss-120b

gpt-oss-120b

Shipped

3–5 s

0

5

10

15

20

* Machine judges favour longer answers.

** Qwen leaked a Chinese character into a Persian card.

The decision

Ship gpt-oss-120b, add a guard that strips foreign-script characters from any model’s output, and keep Qwen as the upgrade path if users complain about card quality.

I picked reliability over a judge score I had concrete reasons to discount.

Challenge 05 of 05 · Inserting

Never paste something the user didn’t say

Situation

Cleanup is done by a language model, and its output is pasted straight into whatever app you’re in. A model can add things you never said.

The decision

If the cleaned text contains a URL that wasn’t in what you said, your clipboard or the context, Cira asks before pasting, with a “Paste anyway” button.

Paste guard · for every URL in the cleaned text

Did it come from what you said, your clipboard or the context?

Yes

Paste

No

Ask first: “Paste anyway”

Why it over-flags

Miss

Could leak data

False alarm

Costs one tap

Paste guard toast with “Paste anyway”

A false alarm costs one tap.

Design decisions and system

One hotkey, and a state for every wait

Input

One hotkey, two gestures

A quick press toggles recording; holding it is push-to-talk. The app tells them apart by how you release the key, so there’s no mode to pick up front.

Same key · read on release

Hotkey

Quick press

Toggles recording on or off

Hotkey

Hold

Push-to-talk: records while held, stops on release

Feedback

A state for every wait

Idle grip, then recording with a live waveform, then Transcribing…, Cleaning up… and Inserting…, then a post-dictation chip with Fix.

Cleanup is shown separately so users can see which step is slow.

State 01, dev build

01 · Idle grip

State 02, dev build

02 · Recording

State 03, dev build

03 · Transcribing…

State 04, dev build

04 · Cleaning up…

State 05, dev build

05 · Inserting…

State 06, dev build

06 · Cancelled

State 07, dev build

07 · Chip with Fix

The full state strip, captured from a dev build.

Placement

Out of the way

Idle, the bar collapses to a grip. It can sit at eight screen edges or flush with the notch, which is measured at runtime. Displays without a notch get a pseudo-notch.

Bar flush with the MacBook notch

Bar on an external display, pseudo-notch

Notch measured at runtime; displays without one get a pseudo-notch.

Engineering

Testable design

All presentation logic (bar state, positions, notch geometry, level meter, ducking math) lives in a pure, unit-tested CiraKit target, separate from the UI.

Brand

A circular C, three colours

A circular “C” mark and three colours: Ink, Cream and Icy blue (#1E140B, #FEF7ED, #BCEFFB).

Brand: logo and app icon

The mark and the icon.

The Cira landing page at heycira.app: headline, hero card and download button. Public marketing page, no personal data.

The landing page that went live on 1 September.

Process

A PRD before any code

Every feature got a PRD with a scoring table (appeal, usefulness, difficulty) before any code. A board and a one-writer-per-file sync log kept six repos consistent.

Diagram: the multi-repo system, six repos and one sync log

Six repos, one agent per repo, one writer per file.

By the numbers

Every dot is one day in one repo, from the first commit on 30 May to the last on 2 Sep. Darker means more commits that day. Read it left to right: before the core went live on 15 Jun, 376 of 380 commits were code. After that the product repo took over, with 247 of its 251 commits: PRDs, research and the board, where the decisions in this case study were made.

Every dot is one week in one repo (one day on a larger screen), from the first commit on 30 May to the last on 2 Sep. Darker means more commits. Before the core went live on 15 Jun, 376 of 380 commits were code. After that the product repo took over, with 247 of its 251 commits: PRDs, research and the board, where the decisions in this case study were made.

739 commits · 6 repos · 30 May → 2 Sep 2026

739 commits · 6 repos · by week

Less

More

1

2

3

4

5

6

mac

312

backend

146

admin

20

product

251

website

9

marketing

1

30 May

15 Jun

1 Jul

24 Aug

2 Sep

1

2

3

4

5

6

mac

312

backend

146

admin

20

product

251

website

9

marketing

1

30 May

15 Jun

24 Aug

1

15 Jun

Live core

Dictation, trial and Stripe in production, about 2.5 weeks after the first commit.

git log; sync log entry for live Stripe, 15 Jun

2

19 Jun

App Attest kept off

Shipping it would have cut every Mac user’s trial from 8 minutes to 2.

Product sync log, 19 Jun

3

29 Jun

Memory v2 UX audit

15 ranked problems turned into 26 design decisions.

research/2026-06-29-memory-v2-ux-problems-and-decisions.md

4

1 Jul

Competitor research

About 80 raw ideas from 12+ products, ranked down to 60.

research/2026-07-01-competitor-reddit-60-ideas.md

5

5 Jul

Model eval

9 personas; judges preferred Qwen 13 of 18, but its Persian cards took 12–18 s against 3–5 s.

Board card, 5 Jul decision

6

1 Sep

Landing page live

The site went up two months after the engine. Distribution was the real gap.

cira-website git log

Source: git log --all --format=%ad --date=short in each repo. Tone = commits that day in that repo: 1–2, 3–5, 6–14, 15+. The hairline folds 8 Jul – 23 Aug, 47 days with no commits.

On small screens each dot is one week. Source: git log --all --format=%ad --date=short in each repo. Tone = commits that week in that repo: 1–5, 6–20, 21–60, 61+. The hairline folds 8 Jul – 23 Aug, 47 days with no commits.

The codebase, counted

Six repos

31

days with commits, out of 96 from first to last

$ git log --all --format=%ad --date=short

896

tests: 436 macOS (410 on prod), 407 backend, 53 admin that gate every deploy

$ grep -E ‘func test|@Test’ Tests · Deno.test · admin CI

12.6k

lines of Swift in 165 files, plus 4.0k lines of tests

$ find Sources -name ‘*.swift’ | wc

17 · 30

Edge Functions · Postgres migrations

$ ls supabase/functions supabase/migrations

14

PRDs, 19 research docs and 111 board cards

# cira-product repo

Not measured

Real users, retention, edit rate, latency. The product never launched publicly and had no product analytics: /stats tracks operations only.

The first things I’d measure are post-paste edit rate, trial-to-Pro conversion, and how many dictations happen in the first session.

What I learned, and what’s next

01

Trust is the product

Every serious problem came down to Cira making an invisible decision (which tone, what it recorded, when the trial ends) and showing the signal somewhere the user wasn’t looking.

Next I’d add a persistent personalization indicator in the menubar.

02

Test the platform before the pattern

App Attest was an “obvious” fix that failed on the real platform. So was merging transcription and cleanup into one /dictate call: the two-step path returned its first token sooner, so the merged call stays behind a flag.

03

Ship the boring parts sooner

The engine was live by mid-June, but the landing page didn’t go live until 1 September and there was no public download link. Distribution was the real gap.

04

Put instrumentation in from day one

I wrote a careful privacy-safe metrics spec, but without shipping it I can’t show whether the design works.

05

Where it stands

Cira isn’t stopped. It works well for my own daily use with native macOS components, and the next step is a proper UI redesign before any public launch. I just haven’t had the time yet.

The code is intact and production is running.