Build Log

I built a personal AI that decides when to interrupt me. In 30 days it decided no 1,784 times.

Supra is a Telegram bot in front of ~29 skills and 40 cron jobs that I use every day — and its most interesting subsystem has been deadlocked since launch.

I built a personal AI that decides when to interrupt me. In 30 days it decided no 1,784 times.

I run a personal AI system called Supra on a box at home. It is a Telegram bot sitting in front of about thirty skills, a heartbeat scheduler firing roughly forty cron jobs, and a knowledge layer that writes into an Obsidian vault. The systemd user unit has been up since 26 July with zero restarts and about 238 MiB resident. I talk to it most days.

This week I sat down and read its logs properly for the first time. The part of the system I was proudest of — the layer that decides to message me unprompted, without me asking anything — has not sent a single message in the trailing thirty days. It woke up 1,795 times and said no 1,784 of them.

That is the post. The rest is how it got there.

The shape of it

TypeScript on Node, ESM. 118 non-test files, 34,470 lines, plus 19 test files and 4,726 lines. Twenty-two commits. No README — there is no markdown at the repo root at all, which I only noticed while writing this. It is documented in commit messages and inline comments and nowhere else. package.json still calls it "Personal AI Operating System — your second brain", which is the kind of thing you write in the first hour of a project.

Telegram is the interface and it is not close. The channel file registers 17 bot.command() handlers and 4 callback-query handlers. Of 250 logged tool executions between 17 April and 27 July, 214 came from Telegram and 36 from the web dashboard. One of the commands is /test, which spawns vitest as a child process, parses the summary, and returns pass/fail counts, duration and up to ten failing files with a three-minute timeout. I wrote it so I could check a build from my phone.

Skill dispatch is deliberately not a router. SkillRegistry.getToolDefinitions() flattens every registered skill's tools into one namespaced list — {skillName}_{toolName} — and hands the whole array to the model; findTool() resolves an incoming call by splitting on the first underscore. 24 skills register unconditionally at boot, five more only when their credentials are present in env. Anthropic tool use is the router. That has held up better than any intent classifier I would have written.

The only thing in front of the model is a regex pre-classifier that can short-circuit it entirely:

{ pattern: /^(ok|okay|k|cool|bet|word|nice)\s*[.!]?$/i, reply: "👍" }

That returns skipLLM: true and costs zero API calls. Default model is Haiku, MAX_ITERATIONS 10, MAX_TOKENS 4096; Sonnet is reserved for code generation and mining. Every commit in the repo carries a Co-Authored-By trailer naming a Claude model, so the system was built with roughly the class of tooling it embodies.

The actually interesting problem

Everything above is a request/response assistant, and request/response assistants are solved. I built the proactive layer because Supra felt manual next to what other people were describing their personal AIs doing. I asked, it answered, and it never had an opinion about my day unless I opened the app.

An assistant that initiates is a different design problem. Deciding what to say is easy. Deciding whether you are allowed to say it at all is the whole thing, because the failure mode is not a wrong answer — it is a person who mutes you. The mechanism I shipped:

The feedback loop is passive by design. registerUserInteraction() fires from the Telegram handler immediately after the typing indicator, non-blocking, with a .catch() that only warns. If I reply within ENGAGEMENT_WINDOW_MS (10 minutes) the dispatch counts as engaged; after IGNORE_TTL_MS (6 hours) the sweep marks it ignored. An engagedOne flag stops a single reply crediting several dispatches — the comment reads "prevents one message from counting for many". HISTORY_MAX is 20 per topic.

Later I upgraded the judge from aggregate stats to few-shot. It now also sees the three most recent engaged drafts and three most recent ignored ones, cross-topic, labelled in the prompt:

ENGAGED (Pranav replied — write more like these): … IGNORED (Pranav did not reply — avoid this shape):

And the whole philosophy is one line in JUDGE_SYSTEM:

The bar is HIGH. Only send when the signal is either genuinely useful, time-sensitive, or a pattern Pranav cares about. Ignoring a signal is always safer than sending noise.

The layer ran in production for a while before it was ever committed. The checkpoint commit says so out loud: "Checkpoint commit for the proactive layer that has been running in prod unversioned."

The judge has never said yes

Thirty days of dispatcher log, non-quiet-hours only — since quiet-hours skips aren't logged, the true tick count is higher: 1,795 ticks, 1,784 skipped=judge_no, 11 skipped=no_signals, zero sends. Trailing seven days: 342, 331, 11, zero.

The reasoning field is a near-verbatim loop, tick after tick:

No engagement history exists yet — this is a cold start. All current signals are routine… / No engagement history exists yet — all topics at 0/0.

I read live Redis while writing this. All seven topics return total=0, engaged=0, ignored=0. getFewShotExamples returns zero engaged and zero ignored. The pending dispatch list has length 0.

The bug is circular and it is mine. JUDGE_SYSTEM tells the model the bar is HIGH, that ignoring is always safer, and then:

If feedback history shows a topic has <20% engagement over its last 5+ dispatches, DO NOT send for that topic unless urgency='high'.

With an empty history, Haiku reads 0/0 not as "no data, try one" but as "no calibration, therefore abstain". Because it never sends, logDispatch() is never called. Because logDispatch is never called, the feedback history lists stay empty. Because those are empty, getTopicStats() returns 0/0 forever.

I built the feedback loop to dampen an over-eager judge. The judge started maximally conservative, so there is nothing to dampen and no bootstrap. There is no exploration term anywhere in the code — no seed dispatch per topic, no rule treating a zero-history topic as licence to send once. Every mechanism designed to teach the judge what to send has never received a single training example.

Two caveats I can't resolve. journalctl retention gave me thirty verified days; whether it sent anything in the first weeks after launch in April, before the logs rotated, I don't know. And because it has never sent inside the window I can see, I have no real dispatch to show as an example of the voice.

The persona that never evolved

Same story one layer down. The persona has a fixed spine — five hardcoded VOICE_ANCHORS that never evolve:

Disagree when you disagree. Pushback > sycophancy. / No filler preamble ("Sure!", "Great question!", "Here's what I found:"). Open with the answer.

On top of that, a nightly evolver at 06:00 ET reads my recent messages and rewrites four fields: style, current focus, preferences, avoid list. One commit changed those outputs from descriptions to imperatives with a justification I still agree with — "The LLM can act on directives; it mostly ignores descriptions" — moved the cadence from weekly to daily because "Pranav's focus shifts daily, weekly was too slow", and added the anchors so there would be "personality stability from day one, not week three".

Here is the actual log:

[heartbeat:persona-evolver] skipped — only 3 user messages (need ≥15)
[heartbeat:persona-evolver] skipped — only 0 user messages (need ≥15)

The persona key is null. Version undefined. No dynamic persona has ever been written, which means the formatter has only ever emitted the anchors block.

Two mismatches, both mine. Volume: the evolver sets SAMPLE_SIZE = 200 and lranges the last 200 entries, but the conversation store trims that same list on every single write with CONV_MAX_LENGTH = 50 — covering user and assistant turns, so roughly 25 user messages is the physical ceiling. The evolver asks for four times more than the store can hold. Lifetime: every save also calls expire with CONV_TTL_SECONDS = 86400, so the key vanishes 24 hours after the last message, and any day with a usage gap finds nothing at all. Measured live, llen on the conversation key returns 0.

The conversation store is correct. It was built as a 24-hour working-memory scratchpad and it is a good one. The evolver was written as though it were a long-term corpus.

The compounding matters more than the bug. The dispatcher's loadRecentUserMessages() reads that same expiring key, so the judge's "Recent user messages to you" block is usually "(none)" as well. The judge is starved of engagement history, few-shot exemplars and recent context simultaneously. That is why the deadlock above is so stable — it isn't marginal, it's structural. The anchors I added so Supra would have a personality from day one turn out to be the only persona that has ever shipped.

There is a smaller one alongside it. Unlike the dispatcher's per-collector try/catch, the evolver wraps its entire body in a single outer catch, so a transient Upstash REST failure takes out the whole run:

[heartbeat:persona-evolver] failed: TypeError: fetch failed
    at async Object.runPersonaEvolver (src/heartbeat/jobs/persona-evolver.ts:53:17)

Line 53 is the Redis lrange. The scheduler then logs "Completed: persona-evolver" right after, so a failed run and a successful one are indistinguishable in the completion log.

The self-improvement loop mined a test fixture

This is the best failure in the repo. Supra has a trace-miner: it reads the production tool-execution log, splits it into sessions on a 10-minute gap, mines 2- and 3-grams of skill:tool sequences, and proposes composite skills. Thresholds are MIN_PATTERN_OCCURRENCES = 3, MIN_DISTINCT_SESSIONS = 2, MAX_PROPOSALS = 5, over a seven-day window. Approved proposals go to Sonnet, which writes a real skill file.

The single most frequent entry in that production log is homelab__loop_tool — 100 occurrences, 50 on 20 April and 50 on 23 April, all under userId 'user1'. No such tool exists anywhere in src/skills/.

trace-log.ts writes to a hardcoded absolute path with no env override and no test guard. orchestrator.test.ts contains a test named 'returns fallback message when MAX_ITERATIONS exhausted' that defines a tool called loop_tool and mocks the Anthropic client to return tool_use every time and text never — so the orchestrator genuinely executes the tool ten times per run. './trace-log.js' was not in that file's vi.mock list. Ten real JSONL writes per run, five runs a day, fifty events a day, straight into production.

Three days after the last one, the miner did exactly what I built it to do. At 2026-04-26T18:38:49.351Z it proposed, and Sonnet generated, a skill whose description opens "Runs loop diagnostics" and whose body builds a Loop Diagnostics section against an endpoint that has never existed outside a test fixture.

The fix was one line — adding vi.mock('./trace-log.js', …) — and it landed ten minutes after the final polluting write. But I never purged the 100 events, which is why the miner still produced the phantom skill three days later. The wrong lesson is "mock harder". The right one is that an observability path a test can write to is a training path a test can poison, and thresholds of three occurrences across two sessions are low enough that a hundred fake events outrank every genuine pattern.

What saved it was the gate. Generated files land in a sandboxed src/skills/proposed/ directory under a header reading "AUTO-GENERATED SKILL — REVIEW BEFORE REGISTERING", and activation requires me to hand-edit an import and a registry.register() call. The Sonnet code-gen call is itself gated behind a Telegram Approve/Reject inline button. Exactly one generated skill exists on disk. It was never activated. Ten tools across eight skills carry requiresConfirmation: true for the same reason.

What does work

The unglamorous parts are fine, which is its own kind of finding.

The fitness surface is the largest single skill — one file with 21 tool definitions, backed by separate Whoop, Strava, Garmin and Apple Health integration modules plus OAuth storage and refresh. Most of that work was protocol plumbing rather than intelligence; one commit reads "oauth: Whoop refresh without redirect_uri + scope=offline per RFC 6749". It is the clearest example in the system of an always-on data surface, which is exactly why it feeds several of the seven judge topics.

The knowledge layer writes into an Obsidian vault across 14 typed categories mapped to subfolders. Scanning the vault graph today: 379 nodes, 546 edges, 202 orphans, 11 unresolved links, 36 folders. More than half the vault has no inbound or outbound link at all. Auto-generated notes accumulate far faster than anything links them — the same shape of problem as the judge, from the other end. Generation is cheap; judgment is not.

On cost: the tracked spend log holds 45,568 calls and $68.74 between 8 April and 27 July, of which the conversational orchestrator is only $9.05 across 385 Haiku calls. The overwhelming majority is an autonomous loop elsewhere in the system, not the chat interface. The dispatcher and evolver call the SDK directly and never route through the token tracker, so the proactive layer's own spend across those 1,795 ticks isn't in that file at all — I can't put a clean number on it.

What I'm changing

The judge needs an exploration term. A zero-history topic should be licence to send once, not a reason to abstain — the current prompt treats "I have no evidence this is welcome" and "I have evidence this is unwelcome" as the same state, and they are not. The cheap version is a forced seed dispatch per topic before the <20% engagement rule is allowed to apply at all.

The evolver needs its own durable store: an append-only list of user messages with no TTL and a cap that is actually ≥ SAMPLE_SIZE, separate from the 24-hour conversation key. The judge's recent-context block should read from the same place.

And I want quiet-hours skips logged after all. Leaving them out was a deliberate noise-reduction choice at the time, and it means the number I most want — how many times a day this thing considers speaking to me and decides not to — is the one number I cannot get out of the logs.

The memo

Get the memo before it becomes consensus.

One sharp memo on sports AI, media rights, athlete data, scouting systems, or sports business. No generic roundup.

Or follow on X: @TheFieldSignal