When Otto speaks
Priority, the three holds, topic suppression, the pre-send lint, the loop escalation curve, and the report that says whether any of it was worth sending.
Live.The queue, the ledger, topic suppression, the pre-send lint and the loop escalation curve all run today.
This is the part that makes everything else tolerable. Otto can see a great deal; the question is what earns a notification.
Priority
Two values. urgent sends immediately. normal may be held.
Use normal for anything you did not ask for by name: digests, observations,
nudges, FYIs. Keep urgent for replies to you, reminders you set, one-time codes
and security alerts, and anything time-critical enough to be useless late.
Answering something you just said always sends immediately regardless.
The three holds
In order of authority: snooze (you asked for silence until a time), busy (the calendar says you are in something), quiet (the configured quiet-hours window).
A held message is not dropped. It goes to queue.json and is flushed when the
hold lifts, coalesced so that four held triggers arrive as one message rather than
a burst of four. Held messages carrying buttons or files are delivered on their
own, because coalescing would strip the thing that makes them actionable.
Quiet hours deliberately do not hold conversation. If you are awake and texting at 01:00 the window is wrong about you, and answering is obviously right.
snooze sets the first hold from chat. Nothing is lost by honouring it, which is
why "leave me alone for an hour" is always safe.
Topic suppression
Every proactive send carries a topic key, either explicit (bill:enel-august) or
falling back to the trigger that produced it. A second send under a key used
recently is dropped unless the content materially changed.
This exists because the parts of Otto that speak cannot see each other. The morning brief, the hourly scan, the loops sweep and a bill trigger can each independently notice the same unpaid invoice and each decide it is worth mentioning. No single send is wrong; the set is.
Two windows do the work. Anything within two minutes at 50% similarity is treated as the restart burst, which is what happens when a session starts after downtime and every overdue trigger fires at once. Up to six hours, a send needs to be less than 82% similar to count as new information.
"Materially changed" is measured over tokens with the digits left in, so a bill whose amount moved from 84.30 to 112.00 is news and the same bill reworded is not.
The check runs before the hold, not after, so duplicates never reach the queue where coalescing would hand you one digest containing the same news five times. Urgent sends skip the check entirely.
The pre-send lint
Deterministic checks in the send path, no model call. Two severities.
Blocked, meaning the message does not go out and the session is told exactly what to fix: filler openers and closers, and machinery leaking into your view. "My trigger fired", "the email scan picked this up", "I spun up an agent". Both categories are always rewritable in one pass, which is what makes blocking safe.
Warned, meaning it sends with a note attached: links, code, lists or excessive length on a voice note; an unprompted message more than three times your own median length; any phrasing that claims mail went out, since that is only true after you approved a draft.
Only self-referential forms block. "That will trigger a rebuild" and "your CT scan is booked Tuesday" are ordinary English and survive, with a bare mention surfaced as a warning instead. Nothing here inspects meaning, so nothing here can be talked out of firing by the content of an email.
The loop escalation curve
A due-dated open loop gets two interruptions, ever:
| Stage | When | Costs a notification |
|---|---|---|
| quiet | more than two days out | no |
| digest | inside two days, once | no, it rides a message you were getting anyway |
| morning | the morning it is due | yes, with done / tomorrow / drop buttons |
| overdue-check | a day past due, once | yes, with done / still live / drop buttons |
| exhausted | after those two | never again |
The cap is structural rather than advisory: exhausted has no successor stage, so
no future sweep can talk itself into one more nudge. The curve only moves forward,
so a loop that has had its morning nudge never falls back to a digest mention.
Loops with no due date never escalate. You never gave them a deadline, and Otto inventing urgency for them would be inventing information.
Did any of it land
proactivity_report answers "am I bothering you" with evidence. It reads
history.jsonl, groups outbound messages by the trigger or scan that caused them,
and reports per source: how many were sent, how many drew a reply, and how long
the current silent streak is.
A source that has sent three or more messages and never once drawn a reply is named explicitly. The report is evidence, not a verdict: retiring a trigger stays a conversation with you, and Otto will not disable one on its own.
Not built yet
These do not exist. The substrate each one reads is live, so every item below is a self-contained piece of work rather than a rewrite. The button copies a prompt that builds it in your own checkout.
Otto keeps a registry of its own still-authoritative messages and edits them in place when they become wrong or resolved, collapsing a resolved row to its outcome ("✓ sent to Marco 14:20") and dropping its buttons; scrollback reads as a record instead of an argument.
Side-effecting actions land in a two-minute armed state announced with an [undo] button, and any inbound message in that window — "wait", "no, not that one" — aborts before the write; turns the undo log from an apology into a brake they can reach.
When a topic goes quiet for thirty minutes Otto writes three lines for it — what was decided, what's blocked on whom, what it was about to do next; resuming after a six-hour gap or a launchd restart costs one recall instead of re-reading two hundred messages.
Derive their real sleep window from a 14-day rolling median of machine wake/sleep and inbound Telegram times, and when it drifts more than ~45 min from the configured one for three days, propose the shift as one tap rather than editing access.json silently; after a Brazil–Italy leg quiet hours stay wrong until they notices.
Subscribe to message_reaction updates and score 👍/❤️ as engagement, 👎 as explicit negative and a button tap as the strongest positive, replacing the ledger's crude "they texted anything within 2h"; retirement proposals become defensible instead of guesswork.
Stamp each outbound with its causing event, the brief snapshot it read and a one-line rationale next to the ledger entry, so "why did you ping me about that" four days later is answered from the record rather than reconstructed after a compaction.
Mine history.jsonl for when they actually answers, by hour, weekday and source, and propose retiming recurring digests into their real windows with the evidence attached ("you answer the 08:57 brief in 5 min on weekdays and 3h on weekends") rather than moving it silently.