DEV Community

Krish Verma
Krish Verma

Posted on

How I built Ankita's proactive loop: scheduled routines, page watches, and a Telegram inbox

Most desktop assistants wait for you to type. I wanted Ankita, my open-source desktop AI assistant, to also act on its own: brief me every morning, keep an eye on pages I care about, and let me talk to the same agent from my phone through a Telegram inbox. This is how the proactive loop works, in the releases that are out today.

One JSON file of intentions

Everything Ankita does on its own lives in a single JSON file, managed by a class called RoutineStore. It holds scheduled routines ("every morning at 08:00, brief me"), URL watches ("tell me when the signup count moves"), and the Telegram getUpdates cursor. Writes are atomic, so a crash mid-save never corrupts tomorrow's schedule.

One detail I almost shipped without: the daemon, the REPL, and the agent's schedule/watch tools each hold their own RoutineStore instance. Without a re-read from disk before every mutation, whichever instance writes last wins with a stale snapshot — so a watch the agent created mid-routine would be silently erased by the daemon's next bookkeeping write. The fix is a three-line _fresh() method that reloads before mutating. Boring, and it saved real data.

Watches: a value, a hash, and no cached copies

A watch is a URL plus either a regex or a CSS selector. A pure function, extractValue, pulls the value out of the page text: match the regex, take the first capture group, and use that as the digest source. Selector watches go through the same scrape tiers the rest of the app uses, so blocked or JS-heavy pages still work.

Change detection is deliberately small. hashContent takes a SHA-256 of the value and keeps 16 hex characters. numericDelta turns "1,204" vs "1,227" into +23, stripping commas and spaces first, and returns null when either side is not numeric — so a headline changing from text to a number never produces a fake delta.

Two rules I am glad I wrote down as code, not documentation:

  1. A watch asks what the value is now. The fetch goes out with caching disabled. Reading a cached copy is how a watch reports "no change" from a page it never actually fetched.
  2. The checker never throws and is shared. The on-demand watch tool and the scheduled daemon call the same checkWatch, so both behave identically and a flaky page shows up as a logged error, never a crash.

Alerts have discipline too. A change only notifies when the watch has notify on and it is outside its alert cooldown (shouldAlert), and changed values are queued into a single message per tick instead of one ping per watch. The daemon keeps a changesAlerted counter, which has been useful for knowing the feature actually fires in the wild.

The daemon is the same agent, on a schedule

The design line I am proudest of: the proactive loop drives the same Agent class the REPL uses. It is "pure-ish by construction" — every side effect (running a prompt, delivering a message) is injected, so the whole loop can be tested without a network or a terminal.

That means scheduled routines are just prompts the agent runs by itself, and the tools that create them — schedule and watch — are available to the model with no approval needed, so the assistant can arrange its own future work. Listing them renders as a plain table:

on  morning-briefing   every day at 08:00   Morning briefing
on  signup-count       every 1h/alert 2h    1,204   Signup count
Enter fullscreen mode Exit fullscreen mode

A Telegram inbox with rough edges filed off

The daemon also polls Telegram, and messages become jobs handled by the same chat pipeline: text replies, voice notes transcribed, optional voice replies back. Three small things took most of the inbox work:

  • The cursor is persisted in the JSON file. Telegram keeps returning every update with an id at or above the offset, so an in-memory-only cursor replays the last batch after every restart — meaning duplicate replies to the user.
  • Approval replies are parsed, not forwarded. The parser understands "y", "yes", "ok", "always" and friends, because approvals arrive as one-word messages.
  • A bare late approval is trapped. There is a guard that catches a late "yes" answering an already-expired approval so it is not fed to the model as a fresh prompt — which previously produced a confused "did you mean to type y?".

What I took away

A proactive assistant is mostly state management and a few paranoid decisions: re-read before mutating, never read a cached copy when "now" was asked for, never throw in the checker, persist the cursor, and batch the alerts. None of these are exciting; all of them are what make it trustworthy when nobody is watching.

Ankita is open source at https://github.com/akyourowngames/A.N.K.I.T.A if you want to read the implementation — the automation lives in src/automation/. If you have built your own scheduled agents or page-watchers, I would genuinely like to hear how you handled the flaky bits: cooldowns, duplicate delivery, and the stale-write problem. Drop a comment.

— Krish

Top comments (0)