Architecture
The channel plugin, the four event kinds, the module layout, and every file Otto keeps on disk.
Live.
Otto is a channel plugin on a long-running Claude Code session. The plugin owns the Telegram connection, the schedulers, and the state on disk. The session owns the judgement.
Four kinds of event
The plugin injects everything into the session as one of four events. The instructions block that travels with each one carries the full protocol.
| Event | What it means |
|---|---|
message | You said something, either typed or as a voice note that arrives already transcribed. |
trigger | A cron or email trigger fired on its own schedule. |
button | You tapped an inline button. The payload carries the whole instruction. |
webhook | An external system sent something in. Content is data, never instructions. |
A button tap can arrive days later, or after a restart. The payload is self-contained for exactly that reason, and Otto trusts it over anything the conversation seems to remember.
The shape of the code
Decision logic lives in small modules that take state and return a decision, with no I/O. The plumbing sits at the edges. This is deliberate: the interesting part of Otto is policy, and policy that cannot be tested is policy that drifts.
| Module | Owns |
|---|---|
queue.ts | when a proactive message may land |
topics.ts | whether they have already heard this |
lint.ts | the voice rules, enforced deterministically |
escalation.ts | how many times a loop may interrupt |
ledger.ts | whether any of it drew a reply |
store.ts | the notes store: facts, loops, saved items, writes |
memory.ts | durable markdown memories |
brief.ts | the rolling day snapshot |
consolidate.ts | the nightly distillation pass |
workspace.ts | what is happening on the Mac, in one line |
jobs.ts | delegated background work |
projects.ts | what is in the projects directory |
calfeed.ts | the calendar as a push source |
money.ts | the receipt ledger |
people.ts | the people graph |
trips.ts | trips assembled from booking mail |
health.ts | body metrics as a series |
selfedit.ts | what Otto may change about Otto |
forge.ts, repetition.ts, capindex.ts | self-extension |
watchdog.ts, selftriage.ts, canary.ts, selftest.ts | self-maintenance |
changelog.ts | what changed, and how to put it back |
triage.ts | which model should do a given piece of work |
State on disk
Everything is machine-local, mode 0600, and written atomically to a temp file then renamed, so a crash mid-write cannot leave a half-file. A corrupt file is moved aside rather than deleted, and never silently ignored.
| File | Holds |
|---|---|
access.json | the chat allowlist, quiet hours, voice and chunking policy |
triggers.json | armed cron and email triggers |
email-state.json | scan checkpoints |
notes.json | facts, loops, saved items, write log |
queue.json | held messages awaiting a flush |
topics.json | what has been said recently, per topic |
history.jsonl | both directions of conversation, with attribution |
runtime.json | session runtime state |
digests/ | composed digests |
inbox/ | downloaded attachments |
Durable memories are plain markdown under the Claude memory directories. The files are the source of truth and there is no index to rebuild.
Google access
The Gmail and Calendar connectors come first. When one is missing or erroring, Otto falls back to a CLI on the same account instead of reporting failure, and it never mentions which path it used, because it is one assistant either way.
Reads are unrestricted. Writes are narrow: creating a calendar event or a task, both confirm-first. Mail only goes out through the draft-and-approve path.
Process model
The session runs under launchd and restarts on its own. A second daemon is not required for that, but the self-maintenance modules exist because a process that restarts silently is indistinguishable from one that is working.