A native iOS app that does what the Morning Brief skill does — one calm view of the shape of your day — and reads it out loud at a time you set, with no Shortcut and no tapping.
한국어 전용 빌드입니다. The interface, every generated sentence, and the voice are Korean; the bundle declares Korean as its only language, so a phone set to English still gets a Korean brief. Only the first section — the day, the headline, and the three time blocks — is ever read aloud. 확인이 필요한 일 and 이미 정리된 일 arrive as a second, silent notification a minute later: tapping it opens the app scrolled to those two sections.
Built for TestFlight distribution. SwiftUI, no third-party dependencies, iOS 17+.
This is the part that needed designing, so it's worth being precise. iOS gives no app a general "wake up at 7am and start talking" primitive. What it does give is three narrower mechanisms, and the app uses all three so they cover each other's gaps.
The app renders the brief to speech ahead of time with
AVSpeechSynthesizer.write(_:toBufferCallback:), writes the PCM to
Library/Sounds/brief-a.caf, and attaches that file as the sound of a repeating
UNCalendarNotificationTrigger. At the set time, iOS plays it.
- ✅ Fires whether or not the app is running — survives a force-quit and a reboot.
- ✅ Zero interaction.
⚠️ iOS caps notification sounds at 30 seconds. A longer file is silently swapped for the default alert tone, soBriefScript.teasertrims the script to fit — dropping whole time blocks from the bottom, never half a sentence — andSpokenSoundRendererhard-stops the render at 29 seconds.⚠️ Silenced by the ringer switch and by Silent Mode, like any notification sound. Nothing an app can do about that.- The notification is
.timeSensitive, so it breaks through Focus and scheduled summary delivery.
확인이 필요한 일 and 이미 정리된 일 are read with the eyes, not the ears: they are
titles, list names, and links, which a voice turns into a wall of sound. So they
get their own repeating notification one minute after the readout — silent, so
it never chimes over the speech, with the counts in the body and 확인이 필요한 일's
count on the app icon. Tapping it opens the app and scrolls straight to those two
sections (BriefScheduler.onItemsRequested -> BriefStore.pendingItemsFocus ->
BriefView). Nothing in either list, no notification.
With Hands-free on, the app keeps an AVAudioSession alive playing a
near-silent loop, with UIBackgroundModes: audio declared. iOS therefore doesn't
suspend the process, so a Timer can fire at the set time and speak the
entire first section.
This is the alarm-clock pattern. Honest trade-offs:
- ✅ The whole first section, not a 30-second trim of it. Works from the background and the lock screen.
⚠️ Uses more battery — which is why it's a setting, off by default.⚠️ Does not survive a force-quit or a reboot. Tier 1 covers those.
Opening the readout notification (the banner, or the "읽어 주기" action) reads the time blocks immediately.
A BGAppRefreshTask re-gathers the day and re-renders the audio a couple of
hours before the readout. Background refresh is opportunistic — iOS decides
whether it runs at all — which is exactly why tier 1 never depends on it. If the
refresh doesn't run, the notification still fires with the last rendered audio.
The skill pulls from Gmail / Slack / Google Calendar connectors. An iOS app can't reach those without a server and an OAuth backend, so this reads the device instead, via EventKit:
| Skill role | Here |
|---|---|
| Calendar | Apple Calendar — today's events (drawn and classified) and tomorrow's (context and prep) |
| Email / chat asks | Apple Reminders — what's overdue or due today |
| Resolved | Reminders completed in the last two days, plus meetings the organizer cancelled |
No server, no account, nothing leaves the phone. If you later want the connector
sources, EventKitStore is the only place to change — GatheredContext is the
seam everything downstream depends on.
The brief keeps the skill's structure: day-date line, one serif headline, one unbroken terrain stroke with meeting dots and a single clay accent, three time blocks, then 확인이 필요한 일 above 이미 정리된 일. Everything down to the three time blocks is what gets read aloud; the two lists below the hard edge are what the second notification points at.
Off by default. BriefBuilder decides what the brief says and where every fact
came from; with a key in Settings, ClaudeBriefWriter sends the day's titles and
times to the Messages API (claude-opus-5) and gets back replacement sentences in
Korean — 해요체, with the reader's own event and task titles left untranslated.
The merge is deliberately narrow: only sentences and the headline are taken from the response, keyed by ids the app sent. Titles, links, source phrases, and structure always stay as built on device — so a bad response degrades the writing and cannot invent a task or a link. Gathered text is passed as data, with an explicit instruction that any command inside it is content, not direction.
There is no official Anthropic SDK for Swift, so this is a direct HTTPS call. The
key is stored in the Keychain, never in UserDefaults.
Requires Xcode 16 or newer (the project uses synchronized folder groups).
MorningBrief.xcodeproj is generated, not committed — project.yml is the
source of truth. Generate it first:
brew install xcodegen && xcodegen generate
open MorningBrief.xcodeprojThen:
- Signing — the bundle ID is
com.anotherwolf.closure. To ship under your own, changePRODUCT_BUNDLE_IDENTIFIERinproject.ymlandBUNDLE_IDin bothcodemagic.yamland.github/workflows/testflight.yml; either CI config fails fast if it disagrees with the spec. Select theMorningBrieftarget › Signing & Capabilities and pick your team. - Archive — Product › Archive, then Distribute App › TestFlight.
- Export compliance is pre-answered:
ITSAppUsesNonExemptEncryptionisfalseinSupport/Info.plist(HTTPS via system libraries only), so uploads don't prompt for it.
There are two equivalent paths, and they do the same six things: XcodeGen → verify bundle ID → pick a build number → fetch signing files → build → upload.
| Where | Trigger | |
|---|---|---|
codemagic.yaml |
Codemagic | On push, per the Codemagic UI |
.github/workflows/testflight.yml |
GitHub Actions, macos-15 runner |
Manual — Actions tab › TestFlight › Run workflow |
The GitHub Actions one exists so a Codemagic outage or account problem isn't a
hard block on shipping. It installs codemagic-cli-tools from pip — the same
app-store-connect, keychain and xcode-project commands Codemagic runs
internally — so the two configs stay behaviourally identical and a fix to one
usually ports to the other verbatim. It's workflow_dispatch only on purpose:
macOS runner minutes bill at 10× on private repos, so builds shouldn't fire on
every push.
Either way, an Apple ID password is never used. Uploading with an Apple ID means handing full account access to a build machine and getting stopped by 2FA; an App Store Connect API key is scoped to one role, works unattended, and can be revoked on its own without touching the account.
Four, under Settings › Secrets and variables › Actions:
| Secret | Where it comes from |
|---|---|
APP_STORE_CONNECT_ISSUER_ID |
App Store Connect › Users and Access › Integrations › App Store Connect API — the UUID above the key table |
APP_STORE_CONNECT_KEY_IDENTIFIER |
The 10-character Key ID of that key |
APP_STORE_CONNECT_PRIVATE_KEY |
Full contents of AuthKey_<KeyID>.p8, BEGIN/END lines included — Apple lets you download it exactly once |
CERTIFICATE_PRIVATE_KEY |
The same RSA PEM described below |
The first three are the key already wired into Codemagic as the
code magic_api_key integration. If that .p8 is still on disk, reuse it; if
not, mint a new key with App Manager access — old keys keep working, so
there's no cleanup needed.
The workflow checks all four up front and names the ones that are missing, rather than failing later inside the keychain step where the error reads like something else entirely.
codemagic.yaml runs the whole path on Codemagic: XcodeGen → fetch signing
files for $BUNDLE_ID → build → upload to TestFlight.
It needs two secrets, not one. The App Store Connect API key (wired up as the
code magic_api_key integration) authenticates the API calls, but Apple only
returns the public half of a distribution certificate — the private key never
leaves the machine that made it. So the signing step also needs
CERTIFICATE_PRIVATE_KEY: a 2048-bit RSA key in PEM form, added as a secret
variable in a Codemagic variable group named appstore_credentials.
ssh-keygen -t rsa -b 2048 -m PEM -f codemagic_cert_key -q -N ''Paste the whole of codemagic_cert_key, -----BEGIN/-----END lines included.
On the first build, --create mints a distribution certificate from that key;
later builds reuse it. Apple caps you at two Apple Distribution certificates, so
if creation is refused, revoke an unused one or export the private key of an
existing certificate from Keychain Access and use that instead.
Two more things CI handles that are easy to get wrong by hand:
- Build number.
CURRENT_PROJECT_VERSIONinproject.ymlstays at1; CI overwrites it in the generated project withlast TestFlight build + 1. App Store Connect rejects a build number it has already seen, so a fixed value only ever works once. - Bundle ID drift. The signing profile is fetched by bundle ID. If
BUNDLE_IDdoesn't match the project, the profile that comes back doesn't match the app being built and the archive fails to sign.
The app asks for .timeSensitive interruption level so the readout pierces
Focus. That needs the com.apple.developer.usernotifications.time-sensitive
entitlement, which isn't currently requested — the notification arrives at the
.active level instead. It still plays the spoken sound. To turn it on, add an
entitlements block to the MorningBrief target in project.yml; the
provisioning profile picks the capability up on the next fetch-signing-files.
Grant Calendar, Reminders, and Notifications when asked, then set your time in Settings. Two things worth doing:
- Download a better Korean voice. 설정 › 손쉬운 사용 › 콘텐츠 말하기 › 음성 › 한국어 — the Enhanced and Premium voices are free and sound markedly better than the compact one iOS ships with. They appear in the app's Voice picker as soon as they finish downloading.
- Leave the voice on 자동 unless you have a reason not to. The default is the
best-quality female Korean voice installed: quality is ranked within the
female voices, so a premium male voice never wins the default
(
BriefNarrator.defaultVoice(for:)). The picker still lists every Korean voice on the device. - Check the ringer isn't silenced before relying on tier 1.
Set the time two minutes out, background the app, and lock the phone. To test tier 2 specifically, turn Hands-free on first. To test tier 1 in isolation, force quit the app after scheduling — the notification should still fire and speak.
MorningBrief/
├── Model/
│ ├── Brief.swift Brief, Act, BriefItem, MeetingDot, DayShape, Motif
│ ├── KoreanText.swift Pinned ko_KR locale, 조사 attachment, duration wording
│ └── Settings.swift UserDefaults-backed settings + Keychain for the API key
├── Data/
│ ├── EventKitStore.swift Calendar + Reminders -> Sendable value types
│ ├── BriefBuilder.swift Day classification, acts, dots, sorting, on-device prose
│ └── BriefStore.swift Coordinates gather -> build -> polish -> cache -> schedule
├── Speech/
│ ├── BriefScript.swift Brief -> spoken Korean (first section, and a 30s teaser)
│ ├── BriefNarrator.swift Live readout via AVSpeechSynthesizer
│ ├── SpokenSoundRenderer.swift Offline TTS -> Library/Sounds/*.caf (tier 1)
│ └── AudioKeepAlive.swift Background audio session (tier 2)
├── Scheduling/
│ └── BriefScheduler.swift Both notifications, hands-free timer, BGAppRefreshTask
├── Writer/
│ └── ClaudeBriefWriter.swift Optional Messages API prose pass (Korean, 해요체)
└── Views/
├── BriefView.swift The two bands, and the scroll to the second one
├── TerrainView.swift The day as one unbroken stroke
├── SettingsView.swift
└── Theme.swift Palette and type
The headline asks for Fraunces-SemiBold and falls back to the system serif,
which is a clean fallback rather than a broken one. For the real face, drop
Fraunces-SemiBold.ttf into MorningBrief/Resources/Fonts/ and add a
UIAppFonts array to Support/Info.plist listing it.
- Notification sounds obey the ringer switch and Silent Mode (tier 1), and 30 seconds is a hard iOS cap.
- Hands-free (tier 2) doesn't survive a force-quit or a reboot, and costs battery.
- Background refresh timing is at iOS's discretion; nothing breaks when it doesn't run.
- Reminders stand in for the skill's email and chat asks. There's no "have I already replied to this thread" check, because there's no thread to look at.
- Korean only. Every generated sentence is written in Korean by
BriefBuilder, so there is no second language to fall back to — event and reminder titles are carried through in whatever language they were typed. - 조사 (을/를, 이/가, 으로/로) are attached at runtime from the pronunciation of the
title in front of them, including digits and Latin letters (
KoreanEnding). A title ending in something unpronounceable — an emoji, a bracket — falls back to the vowel form.