I built an AI assistant that writes code against the surfaces I already live in.
One always-on Mac mini, an orchestrator that dispatches specialists, a denied-by-default broker over my own Apple data. Tsvetana (Цветана) is the assistant's persona; tsvetok is Slavic for flower, and the sigil is a chamomile.
0.22×median brief-pinning compression — actual vs. estimate, N=15 measured slices
414numbered sessions across two architectural eras
32always-on LaunchAgents on one Mac mini
denied-by-defaultaudit-logged broker over my own Apple data
02
Live where I already am, build when there's a gap, route around when blocked
stance
Premise
The best personal assistant meets me on the surfaces already in my day. I'm not stubborn about staying in one place; I use what works best for me. The point isn't convenience — it's data-locality for partnership. To be a coding, planning, and goal-reaching partner that I don't have to repeat myself to, Tsvetok needs the references already there: my emails, texts, notes, calendar, photo stream. The less I retype, the more it can infer, fetch, and over time become proactive. Proactivity is what I'm working toward, and surface-living is the precondition.
Three behaviors, with receipts for each:
Live where I amiMessage, Mail, Reminders, Notes, Calendar, Photos, Keychain were already part of my daily use before Tsvetok existed; Tsvetok writes code against those. Claude Desktop earned a slot because it shows active sessions, accepts drag-and-drop images, and is sometimes easier than SSH-ing into a tmux session from Warp or Ghostty. Claude on iOS earned one because it's the most convenient way into a coding session from my phone. Slack was retired in v1 not on principle — I left the venue. With a team on Slack today, I'd integrate.
Build when there's a gapThe Tsvetok iOS and macOS clients exist because no surface covered briefings, chat with Tsvetana, and a window into memory + system state from where I already work. I view briefings on the phone, chat with Tsvetana on either platform, and browse memory and health when I need to see it; the apps are how that lands. The gap was real, no good third-party fit existed, so I wrote one.
Route around when blockedThe Xcode-MCP TCC dialog is the load-bearing example. When Xcode prompts to allow MCP access, I'm often on a remote surface (Claude Desktop, a Tsvetok client, the iPhone) and can't drive the modal on the mini. So a watchdog opens Xcode when Claude Code does and clicks "Allow" for me. Same pattern with codex dispatch: ~/tsvetok/scripts/dispatch-codex-template.sh routes through my logged-in ChatGPT session because Agent(subagent_type="codex-expert") dies on missing $OPENAI_API_KEY. Necessity, not just convenience.
The principle has two configurable axes. The first is what flows in: some folders stay out — auth keys, credentials, anything that has no business in a general-knowledge layer and a lot of business not leaving its own folder. Others stay in fully: personal mail, iMessage, Notes. The second is who downstream sees what. Even within this one machine, a Zhiva specialist subagent sees a tightly scoped slice through the personal-data broker; the orchestrator on tsvetok sees almost everything. The asymmetry is a privacy boundary on paper, but in practice the bigger win has been performance: too much context pollutes; too little defeats the purpose. What works here is calibrated for me. Configured differently, the same architecture serves a user whose surfaces and boundaries look nothing like mine. This is a rapidly changing dance.
Apple is the consequence of the first behavior, not the rule. The system is Apple-shaped because the surfaces I already live in are Apple-shaped; the pattern would have produced a different stack for someone whose day is in Slack and Linear. The discipline is invariant. The surface is local.
03
What it does for me today
three loops
Receipts before architecture. Tsvetok is running continuously since January 2026: ~414 numbered sessions (~289 narrative retros on disk under ~/.claude/projects/-Users-davidknudson-tsvetok/memory/project_status/sessions/), ~388 stored memories (171 scoped to tsvetok, 12.1 MB SQLite with hybrid BM25 + mxbai-embed-large), and 32 LaunchAgents under ~/Library/LaunchAgents/com.tsvetok.*.plist. Six of them carry the load you would notice if they stopped:
com.tsvetok.briefing-generation.plist — the 19:00 push, below.
com.tsvetok.journal-writer.plist — daily work journal from geofenced photos.
Every evening before my overnight shift, a LaunchAgent fires agents/briefing-agent.py. It gathers today and tomorrow by calling four signed Swift binaries (calendar-query, reminders-query, plus the photos-query and notes-cli tools they share an identity with, all signed Apple Development: David Knudson (4YHTQYLS46) so the TCC csreq blob survives recompile), reads the Apple Mail Envelope Index directly (private SQLite, Apple revs the schema silently from V8 to V9 to V10 so the reader is concentrated in one fix-site), pulls NWS weather, and composes the briefing via claude -p against my Max OAuth session.
No API key is involved. The Anthropic API key file at ~/.tsvetok/config/anthropic-api-key was renamed .disabled-2026-04-15; every Claude invocation in this system now routes through the same subscription I already pay for as a coding tool. The briefing is also the deprioritization receipt: voice and ambient (Phase 1 below) lost their attention to this loop. It earned the slot because I was already going to look at my phone at 19:00.
I work overnight security at a hotel. Part of the job is walking the property and logging anything off — unfamiliar vehicles, side-door damage, arrivals. I already take geofenced rear-camera photos on those walks because that was already my habit; Photos.app already has them. Tsvetok wrote the connecting tissue, not a new app.
At 06:30 each morning, agents/journal-writer.py exports the night's geofenced rear-camera photos, runs them through Claude vision, and writes a Journal entry in Apple Notes via notes-cli. For ad-hoc arrivals notes — when a guest needs a specific addendum attached to the night's PDF — I run ~/tsvetok/agents/addenda/run.sh "<arrivals text>" <count>. The com.tsvetok.addenda-oneshot LaunchAgent inherits Photos + iCloud TCC, photos-query recent pulls the N newest assets, pdf_report.build_pdf renders the addendum + full-page spreads, and the shift PDF in iCloud Drive gets overwritten. Near-zero input from me; the original photos were already on my phone.
Loop C
iMessage as the channel
denied-by-default · self-chat default · routed
Tsvetok pushes to my iMessage when it has something to say. I reply in the same thread. The system routes the reply to whichever agent or workflow asked. From my side it is one chat — the one I already read.
There is no new pane for this loop. The mechanics of how the message gets in and back out are off-limits on this page; the surface is the point.
04
One pattern, shipped: brief-pinning 5–10×
N=15 · S137–S140
Across four sessions (S137 through S140, 2026-04-28 to 2026-04-30), I tracked appetite-vs-actual wall-clock for fifteen distinct work slices dispatched to AI specialists. Median ratio: 0.22× of estimate. Fastest slice: 0.10× (a 3–4 hour estimate that finished in 17 minutes). Slowest: 1.0×. Same model. Same codebase. The variable was how I wrote the brief.
Brief-pinning means naming the contracts the specialist must honor — frozen protocol signatures, frozen error mappings, frozen acceptance criteria, precedent files cited by line range — and leaving implementation choices alone. A pinned brief takes longer to author; the specialist runs much shorter. Net wall-clock collapses.
Representative subset of the N=15 dataset. Six of fifteen rows shown; full table in the appendix Gist.
Slice
Appetite
Actual
Ratio
Z-B6a (specialist refactor)
60–90m
9m
0.13×
Z-C1 (composition root)
3–4h
17m
0.10×
Voice-log undo
30–45m
5m
0.11×
G-2 share-link iOS surface (250–350 LOC)
~1h
17.5m
0.29×
G-3 PDF dossier (≤30 LOC)
30m
8.7m
0.29×
Build 7 visual + anchor surfaces
1–2h
~30m
~0.5×
Median across all 15 slices
0.22×
The 5× isn't magic. Pinned protocols stop the specialist from asking clarifying questions. Line-cited precedent files stop it from grepping for a pattern (and picking the wrong one). Explicit acceptance criteria stop it from adding the speculative refactor it noticed mid-flight. The three effects compound, and the orchestrator's job is to invest in the brief.
Jetson Orin Nano — central reasoning host. FastAPI service, ChromaDB vector store, Claude CLI subprocess for reasoning. The "no API costs" principle was established here, on the same Max OAuth session I still use in v2.
Raspberry Pi 5 + Hailo-8 (26 TOPS) "Satellite" — Camera Module 3, ReSpeaker XVF3800 mic array, 7" kiosk display.
Intel Mac mini, then M1 MacBook Air ("mba13") — bridge host for Apple data sources, Google Workspace, Obsidian, CouchDB. The Intel Mac was duct tape, not architecture: an old box bolted on mid-v1 because the Jetson is Linux and could not host Apple's Calendar / Mail / Photos stack. It later aged out; bridge functions migrated to the MBA on 2026-02-17.
Capabilities built before the consolidation pruned them
Bilingual Russian/English voice pipeline — Google Chirp 3 HD voices, custom SSML forcing Russian pronunciation of "Tsvetana" inside English speech.
UniFi network — events, alarms, DPI, port forwards, firewall awareness.
Why it changed. The face-recognition pipeline emitted face.recognized events at face_recognition.py:317, but satellite/app.py:67–95 only wired gesture events to behavior (hand-raise → wake word, wave → notification). Face events had no subscriber. CV worked end-to-end; the actuation surface stopped at the indicator. Russian Tutor scaffolded as FSRS flashcards only — no lesson curriculum. Ceded to Duolingo (rule: don't rebuild things that already exist well). The throughput gains were in coding-orchestration, not ambient. The file:line citations on this page and on the v1 diagram below are deliberate. they're receipts: verify if you care to.
Phase 2
Disciplined retreat — within v1, then the reset
2026-02-25 onward
Within v1, multiple subsystems were built and then retired in waves: macOS Menu Bar, Observatory, Slack gateway, Cloudflare Tunnel, iMCP, ntfy, apple-mcp, project_bridge.
Commit a7c3869 (2026-02-25) dropped ~5K LOC in a single shot. Even the Satellite itself was decommissioned (b5a6e28) and recommissioned (a4e4437) inside v1.
By the end of v1, ambient/IoT was parked. The v2 reset chose coding-orchestration as the load-bearing surface, and the multi-device fleet collapsed into a single always-on host. What got cut was what didn't earn its rent. The Tsvetok native apps that arrived in v2 are themselves new surfaces, and they earned their place — they cover briefings, chat, and a memory/system-state surface that nothing else covered for how I work.
Phase 3
v2 — single-host orchestrator
2026-03-21 → present
v2 runs on one M4 Mac mini (24GB / 500GB, always-on). The architecture is six layers — orchestrator, council, broker, memory, registry, proactive surfaces — composed against the Apple data layer the bridge host of v1 was already signaling. The duct tape was the answer all along.
The fleet, in full. The Satellite (Raspberry Pi 5 + Hailo-8) also carries a ReSpeaker XVF3800 mic array, hand-raise/wave gesture detection, and a 7-inch kiosk display — camera, mic, and kiosk in one edge node. The Jetson brain additionally runs a ChromaDB vector store and speaks as Цветана (Tsvetana). The Bridge (M1 MacBook Air, mba13) also fronts Google Workspace and Obsidian-over-CouchDB. The dashed gold line is the tell: the Satellite emits face.recognized, but nothing subscribes — it fades into the void at face_recognition.py:317.
v1 fleet → v2 single-host. Same person, sharper convictions about what a personal AI should be.
↻
The bridge host — an Intel Mac mini bolted on mid-v1 because the Jetson couldn't run Apple services — was duct tape, not architecture. v2 arrives at the answer that was always being signaled: an Apple Silicon Mac at the center. The duct tape was the answer all along.
v2Single-host orchestrator · 2026-03-21 → present
SINGLE-HOST · ALWAYS-ON · 24GB / 500GB · M4 MAC MINI · TAILSCALE OVERLAY
APPS · Tsvetok iOS + macOS clients on iPhone, iPad, two Macs BUILD · xcodegen · Apple swift-sdk for MCP
internal data flowtailscale overlay↻ Mac mini returns to the center · hardware-arc closedv2 · single host · 6 layers
06
The composition — six layers
v2 substrate
a Orchestrator + specialist dispatch
The orchestrator is a Claude Code session running on tsvetok. When a slice is large enough to warrant it, the orchestrator authors a pinned brief and dispatches a fresh subagent — in parallel where files are disjoint. Cross-provider dispatch goes through ~/tsvetok/scripts/dispatch-codex-template.sh against my logged-in ChatGPT session; direct Agent(subagent_type="codex-expert") is forbidden because that path tries the OpenAI API and dies on missing $OPENAI_API_KEY. The N=15 brief-pinning data in §04 is this layer's receipt.
b Adversarial council before code is written
For multi-session work, contracts go through PRD_GATE_COUNCIL — Claude Sonnet plus Gemini plus Codex, each reviewing independently against the same PRD. Same-provider review has structural blindspots; cross-provider is what closes them. Confidence trajectory across rounds is the stop signal: up is healthy, flat is treadmill, a ≥0.2 drop is a structural gap. The same logic underwrites the two-stage prose review that vetted this page — a same-provider technical-accuracy pass and a cross-provider AI-slop pass run before David sees a "ready to publish" claim.
c Personal-data broker
Foreign-project Claude Code sessions (a zhiva subagent, say) cannot call mcp__memory__* or mcp__apple-services__* directly — those are denied by project-scope permissions.deny. Personal-data access is mediated through ~/tsvetok/bin/personal-data-broker.py, a per-session MCP stdio server. Denied-by-default. Audit-logged to ~/.tsvetok/state/personal-data-access.jsonl (append-only on disk; seek / truncate / wb near these paths is a pre-commit lint refusal). Fail-CLOSED: if the broker cannot read its state, every tool call returns denied. Grants are per-agent-identity, not per-project-instance.
Dispatch pin (AL-2). Foreign-project subagents must be dispatched with the foreign project's canonical path as cwd, not ~/tsvetok/. A subagent dispatched with tsvetok as its CWD inherits orchestrator-tier MCP access and the broker is bypassed.
d Memory as a journal, not an encyclopedia
The memory store is SQLite with sqlite-vec for embeddings and FTS5 for keyword search; recall is hybrid BM25 + vector against mxbai-embed-large. ~388 memories live in it today (171 scoped to tsvetok, 12.1 MB). What goes in: my decisions, project state, incidents I want to remember, procedures, personal facts. What stays out: external software versions, API capabilities, CLI flags — those get looked up fresh. Negative claims ("X doesn't exist") get a freshness banner and require re-verification before acting. Positive instructions about third-party UI paths get the same treatment, because training-data click-by-clicks decay silently.
e Project registry + write-protection
A typed registry at ~/.tsvetok/state/projects.json is canonical project state — id, canonical_path, state, do_not_propose, aliases. Prose memory never overrides it. Before acting on any project shorthand ("the Swift app", "PetFiles"), the orchestrator runs ~/tsvetok/bin/tsvetok-state.py resolve "<ref>"; exit 3 means STOP. Nine mutating verbs refuse writes unless CWD resolves to /Users/davidknudson/tsvetok or a three-factor allowlist agrees. Mutations append to a flock-protected log. Born from session 115 (the PetFiles-Swift misroute, where a stale carry-forward bullet routed work into a SHELVED project) — the registry is the mechanical answer to that class of failure.
f Proactive surfaces over signed Swift launchd binaries
The system pushes when it has something to say. I don't poll a chat window. 32 LaunchAgents under ~/Library/LaunchAgents/com.tsvetok.*.plist drive the proactive layer; the six load-bearing ones are named in §03. They call signed Swift binaries (calendar-query, reminders-query, photos-query, notes-cli, axorc, plus AppleServicesServer) under Apple Development: David Knudson (4YHTQYLS46) — never ad-hoc, because the TCC csreq blob in TCC.db is identity-bound and survives recompile only when the same identity re-signs. launchd context is also a TCC prerequisite: Photos and iCloud grants don't work when the same binary is spawned from interactive bash.
07
Operating discipline — 8 rules from 74+ feedback memories
crystallized
The operating mode in CLAUDE.md is eight numbered rules. They are not policy imposed from above; they are 74+ feedback memories crystallized into operating mode, with the per-incident detail preserved in git history at ~/.claude/projects/-Users-davidknudson-tsvetok/memory/. The system writes its own development journal — ~289 session retros on disk so far — and each rule has at least one incident behind it. Three worked examples follow.
S143 — three identical onboarding crashes → rule 8 (instrument before iterating)
Build 10 crashed during onboarding. The fix shipped as build 11; it crashed at byte-identical stack offsets. Same for build 12. After the third iteration I dispatched an architecture council; 4 of 4 reviewers reached 0.85 consensus in 152s for ~$2 — the same call after iteration 2 would have prevented iterations 3 and 4. Rule 8 became: byte-identical stack offsets across two iterations is a circuit breaker. Iteration 3 must be diagnostic-only; iteration 2 triggers council, not another patch.
S148 → S166 — seven TestFlight builds shipped on a "cured" claim → rule 6 (verify the bytes)
The IAP flow was reported fixed. Sim verification passed. Tests passed. grep HEAD showed the fix wired. Seven TestFlight builds shipped on that "cured" claim — and not one of them wrote bytes to the subscription, auth_events, or telemetry_event tables in production. Rule 6 sub-rule: a fix is not cured until the production data layer confirms the user-facing flow completed end-to-end. Sim plus grep plus commits-to-main are necessary; none of them are sufficient.
S137–S140 — brief-pinning N=15 → rule 2 (specialist over serial)
The data table in §04 is rule 2's receipt. Pin contracts, dispatch specialists in parallel where files are disjoint, brief for adaptation not slavishness. Median 0.22× of estimate across fifteen measured slices. The orchestrator's job is to invest in the brief; the specialist's job is to ship within the contract.
The eight rule titles, in order:
Pipeline-by-impact
Specialist over serial
Code like it ships
Memory is a journal
Confirm before prod writes
Verify the bytes
Ship before polish
Instrument before iterating
Full framings, sub-rules, and the incident-to-rule mapping for all eight: /tsvetok/discipline →
08
War stories with receipts
four cards
S115 — PetFiles-Swift misroute
Symptom: a session opened "build N" on a project that had been SHELVED two weeks earlier; a stale prose bullet in project_status.md drowned out the actual state.
Cause: prose carry-forward was the source of truth and it had decayed.
Cure: typed registry at ~/.tsvetok/state/projects.json with do_not_propose + alias resolution, surfaced at session start. Registry overrides prose.
Rule: Memory is a journal (4); registry-write-protection enforces the floor.
S143 — byte-identical stack offsets
Symptom: builds 10, 11, 12 crashed at identical offsets in system frames.
Cause: the fix was at the wrong layer; each iteration treated the symptom while leaving the root in place.
Cure: architecture council at iteration 2; instrument-only at iteration 3 (no behavior changes).
Rule: Instrument before iterating (8).
S148 → S166 — IAP zero-bytes
Symptom: the IAP flow looked fixed; seven TestFlight builds shipped on that claim.
Cause: verification stopped at the sim and at grep HEAD; nobody curled the production rows.
Cure: data-layer evidence is now part of the cure-claim ritual — a fix isn't cured until production rows confirm the user-facing flow.
Rule: Verify the bytes (6).
S177 — portfolio SVG, 113.8 units overflow
Symptom: visible text overflowing a panel by ~44% of its width, plus a 184-unit invented halo around a 64-unit pill.
Cause: the verification primitive modeled <text> as a point at (x, y) instead of a getBBox().width-wide run; the algorithmic check verified the model, not the pixels.
Cure: render-and-screenshot gate using a headless Playwright harness; the orchestrator must read at least one screenshot before accepting any visual claim.
Rule: Verify the bytes (6) extended to visual output.
09
Surfaces, status, contact
Surfaces that earned their place vs. surfaces that didn't
The right column isn't a refusal list. Where it names a surface, that surface was tried in earnest and lost out to a path that fit my day better, or to something I already used that covered the same need without another pane to remember.
Earned its place
Didn't earn its place
iMessage (channel)
Slack gateway (retired when I left the venue, not on principle)
Apple Mail (Envelope Index, MCP)
Gmail MCP (replaced by Apple Mail)
Reminders, Calendar, Notes, Photos, Keychain
Obsidian + CouchDB sync (v1; ceded)
Tailscale (private overlay)
Cloudflare Tunnel (retired)
Max OAuth (Claude Code)
Anthropic API key (retired 2026-04-15)
CLI pending-decision ledger
Tailscale-served HTML dashboard (torn down 2026-04-26, S119)
iMessage push (self-chat)
ntfy / Pushover (the self-chat already covered the push need)