This is the authoritative spec for the Markdown files Transcripted saves under
the capture library (<capture-library>/meetings/,
<capture-library>/dictations/, and <capture-library>/writing/). Agents and
standalone tools parse these files, so treat this document as the contract.
Writers live in
Sources/TranscriptedCore/Storage/TranscriptFormatter.swift,
Sources/Meeting/MeetingTranscriptStyler.swift,
Sources/Dictation/DictationTranscriptWriter.swift, and the main app's Writing
day-file writer (never the keyboard). Readers include the app's
Home scanner (TranscriptFrontmatter), Tools/TranscriptedCaptureKit (used by
the CLI and MCP tools), and Tools/TranscriptedQA. If you change the written
format, update this doc, the CaptureKit parsers, and their tests in the same
change.
Two flat frontmatter keys carry the contract:
format_version: 1— the capture-format version. Absent means the file predates versioning; parse it exactly like version 1. The grammar below has not changed since before the key existed — the key was added so future breaking changes have somewhere to land. Bump the number only for a breaking change; additive changes stay within the current version.transcript_style: raw | styled— which meeting body grammar the file currently carries (meetings only; see the lifecycle below). Absent means the file predates the key: sniff the body heading instead (## Full Transcript→ raw,## Transcript→ styled).
Dictation and writing day files carry format_version but no
transcript_style (they have a single body grammar).
- Flat
key: valuefrontmatter only. The app's own scanner (TranscriptFrontmatter.values(from:)) skips indented lines, so nested YAML is invisible to it (see the Issue #500 comment inTranscriptFormatter.swift). New keys must be top-level. The only nested blocks are the pre-existinggap_events,speakers,tags,aliases, andcssclasses; parsers that need them re-read the raw frontmatter lines. - Parsers must ignore unknown keys. Older files may contain summary metadata from features that no longer write new fields. Future app versions may add more keys too.
- Additive changes only within a version. Never rename or remove a key, or
change an existing key's value format, without bumping
format_version. - Frontmatter values may be quoted or bare; parsers strip surrounding double
quotes. Escaping for quoted scalars follows
TranscriptSaver.escapeYAML. - Key order is not guaranteed. Match by key, not position.
A meeting transcript is written once and then rewritten in place up to two times. Any stage after the first can be skipped (the restyle fails closed on bodies it cannot parse; the app can also quit first), so all intermediate shapes exist in the wild:
- Initial save —
TranscriptFormatter.formatTranscriptMarkdownwrites the raw form toCall_<YYYY-MM-dd_HH-mm-ss>.md(collision suffix_<n>), withformat_version: 1andtranscript_style: raw. - Restyle + rename (async) —
MeetingTranscriptStyler.restyleTranscriptrewrites the body into the styled form, re-emitstitle:first, preserves every other frontmatter line (includingformat_version), rewritestranscript_styletostyled(replacing therawmarker — never duplicated; legacy files without the key gain it), and renames the file to<YYYY-MM-dd> <title>.md(collision suffix<n>). Retained audio directories follow the rename (MeetingArtifactRenamer). Unknown trailing Markdown sections from older files are preserved.
The Home title editor may later rewrite title: and rename the file again. It
also preserves unknown frontmatter and trailing sections.
Written at initial save (all flat unless noted):
| Key | Example | Notes |
|---|---|---|
capture_id |
"5E9A…" |
UUID, quoted. Same value as transcript_id. |
capture_type |
meeting |
Discriminator; dictation day files use dictation_day, writing day files writing_day. |
format_version |
1 |
See Versioning. Absent = pre-versioning. |
transcript_style |
raw / styled |
See Versioning and lifecycle. |
transcript_id |
"5E9A…" |
UUID, quoted. |
date |
2026-04-07 |
yyyy-MM-dd, local time. |
time |
09:14:00 |
HH:mm:ss, local time. |
duration |
"12:30" |
m:ss (minutes may exceed 59), quoted. |
processing_time |
"41.3s" |
Seconds with one decimal + s, quoted. |
transcription_engine |
parakeet_local |
Engine identifier. |
transcription_language |
"auto" / "fi" |
Optional immutable requested language for this job. Absent legacy files retry as Automatic. |
transcription_language_resolution |
detected |
Optional explicit, detected, automaticUncertain, multilingual, or unsupported. Detection is bounded evidence, not proof of a recording's only language. |
transcription_language_resolved |
"fi" |
Optional language passed to the recognizer; absent when Automatic remains uncertain, multilingual, or unsupported by that engine. Exception: Apple Speech (apple_speech_local) can't detect languages, so its Automatic uses the Mac's language and writes automaticUncertain together with that code. |
diarization_engine |
nemotron_offline |
The diarizer that actually ran for this meeting. See below. |
voiceprint_model |
"redimnet2-b4" |
Optional, quoted. The model that embedded each speaker's voiceprint. Absent in files saved before 2026-09-29 and when no diarizer ran. |
sources |
[mic, system_audio] |
Inline list of captured channels. |
mic_utterances |
12 |
|
system_utterances |
34 |
|
mic_speakers |
1 |
|
system_speakers |
2 |
|
total_word_count |
1204 |
|
title |
"Weekly Sync" |
Optional at save (imported audio, detected meetings); the restyle always writes one. |
imported_at |
2026-09-24T19:34:04Z |
Imported files only: when the file was transcribed, as a UTC ISO 8601 instant. date/time stay the original recording time; the app lists an import by this key so it shows up where it was just made. Kept when the same file is re-transcribed. |
The transcription_engine value identifies the concrete model used, not a
later picker selection. Current identifiers are additive within format version 1:
| Identifier | Model | Raw transcript footer name |
|---|---|---|
parakeet_local |
Parakeet TDT v3 (default) | Parakeet (preserved for compatibility) |
parakeet_v2_local |
Parakeet TDT v2 (English only) | Parakeet V2 |
parakeet_ultra_local |
Parakeet Ultra (experimental, installed by scripts/models/parakeet-ultra) |
Parakeet Ultra |
whisper_large_v3_turbo_local |
Whisper Large v3 Turbo | Whisper Large V3 Turbo |
whisper_large_v3_local |
Whisper Large v3 | Whisper Large V3 |
apple_speech_local |
Apple Speech (macOS SpeechTranscriber) | Apple Speech |
Readers should preserve unknown identifiers rather than assuming every local capture uses Parakeet v3.
diarization_engine records the backend that diarized the meeting, read when
the diarizer ran, not the one the app asked for. Values are additive within
format version 1:
| Value | Meaning |
|---|---|
nemotron_offline |
NVIDIA Nemotron 3 Diarization (the app default since 2026-09-28). |
pyannote_offline |
pyannote community-1. Also what a Nemotron load failure falls back to. |
none |
No diarizer ran (for example a mic-only meeting without local speaker split). |
Files saved before 2026-09-29 always say pyannote_offline, even when Nemotron
diarized them, so treat that value in older files as "unknown backend".
voiceprint_model uses the embedder's identifier: redimnet2-b4 (default),
eres2net, wespeaker (pyannote's native offline WeSpeaker), or
wespeaker-fluid-online. Missing means unknown; readers keep unknown values.
The raw footer names the diarizer too (Parakeet + Nemotron (local), or just
Parakeet (local) when none ran); like the STT name there, it's descriptive. The footer is descriptive; use the frontmatter key
for model identity.
The selected language survives saved-audio retranscription and failed-job retry. Automatic detection is resolved once per pipeline invocation from at most three non-overlapping, speech-candidate windows of up to ten seconds spread across the recording. Its result is shared by both channels and all speaker segments. A retry can resolve Automatic again using its current engine; the previous resolved language is descriptive metadata, not a permanent forced override.
Recording-health keys (optional, only when health info exists):
| Key | Example | Notes |
|---|---|---|
capture_quality |
excellent |
excellent / good / fair / degraded. |
audio_gaps |
0 |
|
device_switches |
0 |
|
system_audio_signal_verified |
true |
Optional flat boolean: finite nonzero system PCM was observed during this live recording. false means unverified, not permission denied; ordinary silence after observed signal remains true. Omitted for imports and legacy captures, for "Record Just My Mic", and when no system audio was heard but macOS confirmed System Audio Recording access (a quiet Mac). Independent of capture_quality. |
gap_events |
— | Nested list of quoted strings; invisible to flat parsers. Omitted when empty. |
audio_health |
mic_attenuated_by_call_app |
Flat; omitted for healthy meetings. |
mic_boost_prompt |
"declined" |
Flat; only alongside audio_health. |
microphone_audio_unusable |
true |
Flat; microphone audio was missing or unusable and excluded from a system-audio-only partial transcript. |
system_audio_missing |
true |
Flat; no system track survived to save, so the transcript holds only the mic side and capture_quality is degraded. |
mic_only |
true |
Flat; the user picked "Record Just My Mic", so only their side was recorded on purpose and the other side of any call is not in the transcript. Not a capture problem: the grade is unaffected and system_audio_missing is never set with it, and sources lists only the mic. A retry or Home re-transcribe keeps the key but writes no capture_quality/audio_gaps/device_switches, since nothing measured the capture. Omitted for two-sided meetings. |
Speaker metadata (optional, nested speakers: block — flat parsers skip
it; CaptureKit and the app re-read the raw lines):
speakers:
- id: "0"
channel: mic # mic | system; older files may omit (treat as system)
db_id: "1111…" # optional persistent speaker DB UUID
name: "Justin"
confidence: high # high | medium | manual | unknown
source: db_scanObsidian metadata (legacy, read-only): older transcripts written while the
retired enableObsidianFormat flag existed may carry nested tags:,
aliases:, and cssclasses: blocks, with named body speakers wrapped in
[[wiki links]]. New transcripts never include these, but parsers and
speaker-rename rewrites still tolerate and preserve them.
Older files may contain auto_summary_*, local_summary_*, or other summary
frontmatter and trailing sections. These are legacy data: current meeting
writers leave them untouched, and current Home surfaces do not create new AI
summary content.
Written at initial save. Filename Call_<YYYY-MM-dd_HH-mm-ss>.md.
# Meeting Recording - Apr 7, 2026 at 9:14 AM
**Duration:** 12:30 | **Words:** 42 | **Utterances:** 2
---
---
## Channel & Speaker Analytics
### Microphone (You)
- **Utterances:** 1
- **Words:** ~20
- **Speaking Time:** 22s
### Meeting Audio (Remote Participants)
- **Utterances:** 1
- **Words:** ~22
- **Speaking Time:** 31s
- **Speakers Detected:** 1
#### Remote Speaker Breakdown
- **Sarah:** 1 utterances, ~22 words, 31s
---
## Full Transcript
[00:00] [Mic/You] Thanks for making time today.
[00:04] [System/Sarah] Happy to help. Let's get started.
---
*Generated by Transcripted with Parakeet + PyAnnote (local) | Duration: 12:30 | 42 words | 2 speakers*Transcript grammar: one entry per line under ## Full Transcript, blank line
between entries:
[<MM:SS or HH:MM:SS>] [<Mic|System>/<speaker label>] <text>
Elapsed timestamps use MM:SS below one hour and HH:MM:SS from one hour
onward, truncating fractional seconds: 59:59, 01:00:00, 100:00:00.
Hours have at least two digits and do not wrap at 24. Readers also accept older
total-minute timestamps such as 60:00 and 6000:00, and unpadded hours such
as 1:00:00. Speaker renaming preserves the original timestamp spelling.
These already-supported clock forms remain within format version 1; duration
metadata and speaking-time summaries keep their existing formats.
Mic labels default to You (or a named speaker with local-speaker review);
system labels default to Speaker <n> until named. In legacy
Obsidian-formatted files, named labels are wiki-linked: [System/[[Sarah]]]
(read-side tolerance only; new files never write this). Multi-speaker mic
captures add a ### Microphone (People in the Room) heading and a
#### Local Speaker Breakdown section. When mic capture is disabled the
microphone section is omitted and sources reflects it.
Produced by the async restyle. Filename <YYYY-MM-dd> <title>.md.
---
title: "Meeting with Sarah"
capture_id: "5E9A…"
capture_type: meeting
format_version: 1
transcript_id: "5E9A…"
date: 2026-04-07
time: 09:14:00
…all other preserved keys…
transcript_style: styled
---
# Meeting with Sarah
Recorded Apr 7, 2026 at 9:14 AM • 12 min, 30 sec • 42 words • 2 turns
## Transcript
**00:00** [Mic/You]
Thanks for making time today.
**00:04** [System/Sarah]
Happy to help. Let's get started.Details:
- The detail line joins parts with
•(two spaces around a bullet):Recorded <medium date, short time>, humanized duration, word count and turn count when nonzero. - Transcript entries are blank-line-separated blocks: a header line
**<MM:SS or HH:MM:SS>** [<Mic|System>/<label>](two spaces after the bold timestamp, with the same elapsed-time and legacy-read rules as raw transcripts) followed by the utterance text on the next line(s). - An empty transcript renders as
_No transcript captured._. - Unknown trailing Markdown sections, including old generated summary blocks, are preserved after the transcript.
- The restyle fails closed: if the body carries transcript text the entry
parser cannot understand, the file is left byte-for-byte untouched (still
raw-form, still
transcript_style: rawif it had it).
Sources/Dictation/DictationTranscriptWriter.swift appends completed
dictations into one file per local day:
<capture-library>/dictations/Dictations_<YYYY-MM-dd>.md.
---
title: "Dictations for April 7, 2026"
date: 2026-04-07
capture_type: dictation_day
format_version: 1
---
# Dictations for April 7, 2026
## 9:15 AM - first note from the morning
Entry ID: `dictation-20260407-091500-123-8c…`
Captured: 2026-04-07T09:15:00.000Z
Source app: Slack
Bundle ID: `com.tinyspeck.slackmacgap`
Delivery: pasted
Words: 5
Characters: 27
first note from the morning
## 4:45 PM - second note from the afternoon
…Details:
- The day header is written once, when the file is created; later saves append
sections only. Header keys are flat;
format_versionfollows the same convention as meetings. - Two edits rewrite a day file in place: Delete removes one section, and
Transcribe again replaces one section's heading title,
Words:,Characters:, and body.Entry ID:,Captured:, the source app,Delivery:, andAudio:never change, so readers can key on the Entry ID. - Section heading:
## <h:mm a> - <title>where the title is the first ~7 words of the text (orDictation <MMM d> at <h:mm a>for very short text). - Metadata lines, in order:
Entry ID:(backticked),Captured:(ISO 8601 with fractional seconds),Source app:, optionalBundle ID:(backticked, omitted when unknown),Delivery:,Words:,Characters:, optionalAudio:(backticked). Older files may carryTimestamp:instead ofCaptured:. Audio:is present only when the take's audio was kept (Settings → Storage → Keep dictation audio). Its value is a path relative to the dictations folder,audio/<session-uuid>.m4a. The file may be the.wavsibling while compression is pending, and it may be gone entirely once it ages out of the keep window or the user switched to Don't keep: readers must treat a missing file as normal and never resolve the path outsidedictations/audio/. Additive withinformat_version: 1; older readers skip the line.Deliveryvalues:pasted,copied,failed,saved_without_paste.- The dictated text follows after a blank line and runs to the next
##heading or end of file.
The main Transcripted app (never the keyboard) appends settled typed text into
one file per local day:
<capture-library>/writing/Writing_<YYYY-MM-dd>.md, in a writing/ folder
beside meetings/ and dictations/. The shape extends dictation day files and
follows the same stability rules.
---
title: "Writing for September 25, 2026"
date: 2026-09-25
capture_type: writing_day
format_version: 1
---
# Writing for September 25, 2026
## 10:42 AM - Pushing the launch to Thursday so QA
Entry ID: `writing-20260925-104211-387-4f2a9c1e`
Captured: 2026-09-25T15:42:11.387Z
Source app: Slack
Bundle ID: `com.tinyspeck.slackmacgap`
Words: 12
Characters: 65
Accepted words: 3
Pushing the launch to Thursday so QA can finish the AirPods pass.
## 11:05 AM - …Details:
- The day header is written once, when the file is created; later saves append sections only, same as dictations. Header keys are flat.
- Section heading:
## <h:mm a> - <title>where the title is the first ~7 words of the text (orWriting <MMM d> at <h:mm a>for very short text). - Metadata lines, in order:
Entry ID:(backticked,writing-<yyyyMMdd>-<HHmmss>-<SSS>-<8 hex>)Captured:(ISO 8601 UTC with milliseconds; the entry's first keystroke)Source app:(display name)- optional
Bundle ID:(backticked, omitted when unknown) Words:Characters:Accepted words:(words that came from accepted suggestions)
- There is no
Delivery:line; nothing is pasted. - The text follows after one blank line and runs to the next
##heading or end of file. It is the settled text: characters removed with plain Backspace inside the keyboard's own buffer aren't saved. Option- or Cmd-Backspace breaks the segment instead, so the word it deleted stays in the saved text. Pastes, mouse edits, and the host app's autocorrect aren't seen (a known v1 limit). - A text line that starts with
##is written as\##so it can't open a new section. Readers strip that one backslash (CaptureMarkdownParser.unescapeWritingBody). So a line that literally starts with\##doesn't round-trip: it reads back as##. Whole-day reads (MCPread_writingwithoutentry_idwhen it returns the file unpaged, andread-writingwithout--entry-id) return the raw Markdown with the escape still in it. Entry reads, pages, and parsed entries are unescaped. - An entry is one app's continuous writing. A new entry starts on an app switch, after 2 minutes idle, or on a caret jump or segment break the keyboard reports. A caret the host reports late, or never moves (some terminals report it at 0), isn't a jump: a word typed in one go is saved as that word. A scrap under 3 words (a quick "sounds good") doesn't end at a segment break when the same app's next segment starts within a minute: it folds into that entry, each segment on its own line. Box fragments (one character, or up to 5 characters with no letters, like a card or code box) don't count toward the 3 words, so a card or code typed over several boxes stays one entry. Entries under 2 characters after trimming aren't saved.
- Nothing is written for secure input, excluded apps (password managers always), apps outside the user's scope, or while Save my writing is off.
- Secrets are scrubbed before an entry is written: a password typed at a
terminal prompt (
sudo,ssh,passwd,read -s, …), a line that is only a password-shaped token, one-time codes and PINs, card numbers (also split across boxes, with the expiry and CVV after them), values after labels likepassword:,API_KEY=,--tokenorBearer, credentials in URLs, and known token formats. Each becomes⟨redacted:<kind>⟩, where kind ispassword,code,card,secret,api-key,jwt,pem,ibanorssn. Emails and phone numbers are kept. An entry that was nothing but a secret is saved as just its token (a sudo password becomes a⟨redacted:password⟩section), so the command before it never runs into the line after it on disk.Words:andCharacters:count the scrubbed text, and the heading title comes from it. - Day files written before the scrubber (or under older rules) are
rescrubbed once when Writing starts: changed sections get their title and
counts redone (
Accepted words:never more thanWords:), sections that were only a secret keep their heading with just the⟨redacted:…⟩token as their text (so the command before them never runs into the line after, and a second pass changes nothing), and files with nothing to scrub aren't touched. A file that changes while it's being rescrubbed, or that Writing stopped before reaching, is left for the next launch. One that can't be read as a day file at all (a symlink, not UTF-8) is skipped and doesn't hold the version back. The rules version it ran with is remembered per Mac (WritingDayFilesScrubbedRulesVersion), so day files that arrive later from another Mac aren't rescrubbed until the next rules bump, and nothing runs while Writing is off. - Files are created 0600 and the
writing/folder 0700.
- Detect meetings via
capture_type: meeting; fall back to the transcript headings for pre-capture_typefiles. Detect dictation day files viacapture_type: dictation_dayor theDictations_filename prefix. - Detect writing day files via
capture_type: writing_dayor theWriting_filename prefix. Check for them before falling back to "any other Markdown file with frontmatter is a meeting", or they get read as meetings. - Choose the meeting body grammar by
transcript_stylewhen present, else by heading:## Full Transcript(raw) vs## Transcript(styled). Robust parsers accept both grammars in either file (seeTools/TranscriptedCaptureKit/Sources/TranscriptedCaptureKit/CaptureMarkdownParser.swift). - Treat every key as optional. Missing
format_version= version 1 semantics. Tools/TranscriptedCaptureKitexposesformat_version/transcript_styleas optionalformatVersion/transcriptStylefields onParsedMeetingCaptureandformatVersiononParsedDictationDayCaptureandParsedWritingDayCapture(CaptureMarkdownParser.parseWritingDay). A dictation entry'sAudio:line isaudioRelativePathonParsedDictationDayCapture.Entry(nil when absent). Detect writing day files viacapture_type: writing_dayor theWriting_prefix, and check for them before the "frontmatter means meeting" fallback.