Otto

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:

StageWhenCosts a notification
quietmore than two days outno
digestinside two days, onceno, it rides a message you were getting anyway
morningthe morning it is dueyes, with done / tomorrow / drop buttons
overdue-checka day past due, onceyes, with done / still live / drop buttons
exhaustedafter those twonever 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.

#25Living messagesmedium

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.

#30Pre-commit hold windowmedium

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.

#33State of play snapshotsmedium

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.

#35Sleep-inferred quiet hoursmedium

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.

#36Reaction and tap engagement signalssmall

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.

#37Send provenance and why-did-yousmall

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.

#40Attention-window schedulingmedium

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.

On this page