| # Field Notes β building the iMessage β Calendar agent |
|
|
| What I set out to build, where reality bent the plan, and what I'd do next. This is |
| the "what I learned" companion to the product docs ([README](./README.md)) and the |
| design doc ([PLAN](./PLAN.md)). |
|
|
| ## The goal in one line |
|
|
| Turn the calendar logistics buried in a chat thread β *"picture day moved to |
| Thursday 9am", "soccer is Tuesday now"* β into reviewed calendar events, from a |
| phone, with the data staying private. |
|
|
| ## 1. "Read my iMessages" is impossible as literally asked β and that shaped everything |
|
|
| iOS exposes **no API** for iMessage/SMS *content*. There is no on-device path. The |
| only place the messages exist in a queryable form is a Mac, where they sync to |
| `~/Library/Messages/chat.db`. So the architecture forked early: |
|
|
| - A Mac-side collector ([`collector/collector.py`](./collector/collector.py)) reads |
| `chat.db` (read-only, `mode=ro`) and POSTs new rows to the Space. |
| - "On my phone" was reinterpreted honestly as **used from** a phone browser β the |
| Space is hosted, the UI is mobile-friendly, but the model runs in the Space. |
|
|
| The biggest adoption lesson came later: requiring a Mac collector + Full Disk |
| Access is a wall for a non-technical user. The fix was to make **paste-from-phone** |
| the hero path (the collector is now strictly optional) β no install, no DB, no |
| permissions. Most of that capability already existed in the Schedule tab; it was just |
| framed as secondary. |
|
|
| ## 2. `attributedBody` is the iMessage parsing trap |
|
|
| Modern Messages often stores the body in `attributedBody` (an `NSAttributedString` |
| binary blob), **not** the `text` column. The collector reads `text` directly for |
| simplicity and **skips messages that only have `attributedBody`** |
| ([`collector/collector.py:88-94`](./collector/collector.py)) β a deliberate, called-out |
| gap. The right move for production is to not hand-roll this: use `imessage-exporter` |
| (ReagentX) or `imessage_reader`. Noting the limitation in code beat pretending the |
| naive SQL was complete. |
|
|
| ## 3. Relative dates are the real accuracy battleground |
|
|
| The hard part isn't "is there an event" β it's *when*. "Next Thursday", "the 14th", |
| "in two weeks" only resolve against a reference time. Two design responses: |
|
|
| - The system prompt pins **"Current datetime"** into every request and instructs the |
| model to resolve relative dates from it ([`server/agent.py:21-34`](./server/agent.py)). |
| - **Conflict math is deterministic, not model-driven.** Overlap/adjacent/tight |
| detection and alternative-time proposals live in |
| [`calendar_out/freebusy.py`](./calendar_out/freebusy.py), because once you have ISO |
| datetimes, interval math should never be left to an LLM. The model decides *what*; |
| code decides *when-it-clashes*. |
|
|
| The stub extractor's naive "match a time β 1h event tomorrow" |
| ([`server/agent.py:152-175`](./server/agent.py)) is intentionally dumb β it exists to |
| prove the pipeline, and its dumbness is a good reminder of exactly how much the |
| fine-tune has to get right. |
|
|
| ## 4. Stub-first was the best architectural call |
|
|
| `USE_STUB_EXTRACTOR=1` swaps the model for a regex heuristic |
| ([`server/agent.py:85,124`](./server/agent.py)), forced on in tests |
| ([`tests/conftest.py`](./tests/conftest.py)). Payoffs: |
|
|
| - The whole app β paste β events β conflicts β `.ics` download β impact panel β |
| **works end-to-end with no GPU**, so a demo (and CI) never depends on a model load. |
| - `llama_cpp` and the Google libs are **lazy-imported**, so `requirements-ci.txt` can |
| exclude them and the test suite runs in seconds, offline. |
|
|
| Lesson: make the expensive dependency optional from day one and the cheap path |
| becomes your test harness, your demo, and your free tier all at once. |
|
|
| ## 5. Reframing around one person changed the scope more than any feature |
|
|
| The project started as a four-track hackathon checklist. Rewriting it around a single |
| named person β a **busy parent** whose kid's events are buried in a class group chat β |
| forced three concrete changes: phone-paste as the default, a one-tap **Try a sample** |
| class-chat ([`ui/blocks.py`](./ui/blocks.py)), and a **"This week"** impact panel. |
|
|
| On measurement: `minutes_saved` ([`server/impact.py`](./server/impact.py)) is a |
| **configurable estimate, not a measurement** (default 8 min/event + 15 min/conflict). |
| Saying that plainly β in the UI, the README, and here β matters more than a |
| bigger-looking number. A capture is only counted when the parent *accepts* events by |
| exporting them, so the metric tracks value taken, not previews shown. |
|
|
| ## 6. Fine-tuning economics: Modal credits + honest scope |
|
|
| QLoRA on a 31B needs an 80GB GPU. [`training/modal_train.py`](./training/modal_train.py) |
| wraps the existing `train_qlora.py` + `export_gguf.sh` to run on a serverless |
| A100/H100 and publish the GGUF to HF β roughly **$5β15 per run**, so ~$250 of credit |
| is 15β40 iterations. The "Well-Tuned" track went the distance: the eval-gated **E4B** fine-tune is |
| published and is what production serves β |
| [`build-small-hackathon/gemma-4-cal-gguf`](https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf) |
| β after clearing the gate over six runs at zero quality cost vs. stock E4B. (Re-running the pipeline |
| still spends your own Modal credits; the turnkey path is there whenever you want to retrain.) |
|
|
| A small rule that paid off: training-data generation can use *any* offline tooling β |
| the "no cloud AI API" rule applies only to the **running app's inference**, not to |
| dataset prep. |
|
|
| ## 7. Two models, not one β a 1B planner over the same tools |
|
|
| What shipped is two small local models, not one. The fine-tuned **gemma-cal E4B** does the |
| *reading* (thread β validated `ActionPlan`); a 1B **OpenBMB MiniCPM** does the *orchestrating*. |
| Clicking **Run the agents** hands the job to MiniCPM, which drives the Space's own MCP tools β |
| `extract_events β check_conflicts β make_ics` β as a visible multi-step agent |
| ([`server/orchestrator.py`](./server/orchestrator.py)), consuming the *public* tool contract |
| instead of calling internals. Two things I'd underline: keep the planner **optional** (a |
| deterministic scripted plan is the fallback, so the agentic path never hard-depends on a second |
| model load), and don't let "agent" become a separate destination β the same **Run the agents** |
| action drives both the home workflow and the orchestrated trace, so it stays one engine, not a |
| second UI to keep in sync. |
|
|
| ## 8. The Off-the-Grid tension |
|
|
| "No cloud AI APIs" and "serve a 31B" pull against each other: a Q4 31B GGUF is |
| ~18β20GB and needs a GPU. Keeping inference **in the Space via `llama.cpp`** preserves |
| the privacy story but costs GPU. The honest compromise is the **E4B edge variant** for |
| the free tier, with the 31B as the headline. I deliberately did **not** offload |
| inference to a third-party endpoint, because "your own Modal GPU" and "a cloud AI API" |
| are easy to conflate and a purist judge would be right to dock it. |
|
|
| The same principle drove the trace-sharing design (below): the hosted Space holds **no |
| HF token** β it only offers a **local download**, and a separate local CLI does the |
| upload with your own auth. |
|
|
| ## 9. What I'd do next |
|
|
| - **Durable trace/metrics store.** The activity bus is an 800-entry in-memory ring |
| buffer ([`server/events.py`](./server/events.py)) β runs are lost on restart, so only |
| recent runs are exportable. A small append-only store (the impact log already shows |
| the pattern) would fix it. |
| - **Decode `attributedBody`** (or adopt `imessage-exporter`) so text-less messages stop |
| being dropped. |
| - **A real eval set** from the expanded dataset β measure JSON validity + field |
| accuracy, especially relative-date resolution and empty-list-on-chitchat. |
| - **Trace redaction as a tested invariant.** Today it's an allowlist over current emit |
| sites ([`server/trace.py`](./server/trace.py)); a lint/test that fails when a new |
| `emit(...)` puts free text on a non-`ingest` stage would keep it honest as the code |
| grows. |
|
|
| ## Publishing these notes |
|
|
| This file is linked from the README. It can also be pasted into the Space's README |
| (the Space card renders Markdown) or posted to the model/dataset repo's **Community** |
| tab on the Hub so others can learn from the build. |
|
|