Ankita v2.5.0: a desktop companion island that never misses an approval
Version 2.5.0 of Ankita — my open-source desktop AI assistant — shipped yesterday. The headline feature is a companion "island" that lives on the top edge of the screen, driven by the assistant's real threads, tools and approval flows. But the change I'm proudest of is invisible: MCP tool calls now go through a four-level approval gate where a heuristic can suggest but never deny, and no scheduled job can quietly loosen its own protection.
The island: real state, not a widget
The island (368px wide, in compact/expanded/petit modes, plus a 14px wake strip to peek it back out) shows up to four teammate mascots in an activity grid. It is not a second UI bolted on — it subscribes to the same underlying state the chat window uses. Approvals that arrive while the main window is minimised still surface; restoring or minimising keeps the same draft and companion window.
The mechanism worth knowing about: the approval registry stores pending requests on entry so late subscribers (like the island) can pick up what was asked while they weren't listening. Activity rolls upward instead of needing a history scrollbar.
I'll be honest: my own build-in-public teaser post last week (before this shipped) got almost no traction on dev.to. Fair enough — the audience wants shipped code, and this is shipped. 931 tests ran on the Windows release workflow: 899 passed, 32 skipped, 0 failed, plus nine grouped native checks against the unpacked runtime covering real file tools, approval flows, capture pairing and project surfaces.
A mascot that eats your files
File capture got character: drop a supported file on the selected mascot and it opens its mouth, swallows, and visibly chews while the document reader works, then reacts to success or failure. Both desktop and island use the existing document/image readers, and attachments stay with their original teammate. Reduced-motion settings skip the swallow animation entirely. It is silly, but it is also a genuine affordance — the animation is the progress indicator.
Capture from the browser, without trusting the browser
A second companion piece: a Chrome/Edge MV3 helper extension that captures readable page text when you drop the mascot onto an HTTP/HTTPS page. The interesting part is the pairing design:
- Pairing runs over the desktop's authenticated loopback bridge with single-use drag tickets — replayed, expired or cancelled tickets are rejected, and pairing survives browser restarts.
- Captures preserve existing drafts, queue in an inbox when attachment slots are full, and never trigger a model request automatically. The extension ships in the package, is loaded unpacked separately, and is not store-published.
This is deliberately paranoid, and I'd rather have paranoid here: a tool that reaches into your browser is exactly the thing that should not silently act.
Keyless Composio sign-in: nothing to host, nothing to leak
Connecting an app used to mean an API key in an env var. v2.5.0 replaces that with a browser OAuth 2.1 + PKCE flow: discovery, dynamic client registration as a public client (no secret issued), a single-use http://127.0.0.1:<port>/callback loopback redirect, and a PKCE S256 code exchange. No public callback URL, no domain, no certificate, no broker to stand up.
The credential hygiene part: the access token is written to the OS vault; the grant file holds only a grantId, endpoints, apps and timestamps — never the token, verifier or refresh token. Revoking deletes both in one step. The old COMPOSIO_API_KEY and COMPOSIO_BROKER_URL paths keep working until the deprecation window closes.
MCP approval tiers: the heuristic can never deny
Previously, a connection's trusted flag could skip the approval gate — a read-only-hint rule with a bypass. That is now gone. Tool calls resolve a four-level tier:
blocklist → explicit tool rule → per-app rule → locked app defaults → heuristic
Auto-allow, ask once, always ask, deny. Composio meta-tools are classified by the worst action they enclose — so COMPOSIO_MULTI_EXECUTE_TOOL wrapping GMAIL_SEND_EMAIL always asks, while read-only catalog discovery stays frictionless. gmail ships locked to always ask out of the box.
Two design choices I want to be explicit about, because they're the ones someone could reasonably disagree with:
-
A heuristic can never deny. The last resolution step can recommend
allow,askorask-once, but neverdeny. The reasoning: heuristics are fuzzy, and a fuzzy rule silently dropping tool calls would make the agent look stupid and untrustworthy. Denials must be explicit and auditable. -
Loosening is refusal-guarded. The new
composio tiers,allow,alwaysanddenyactions inspect and change the gate — but a call that lowers protection can never run under auto-approve. An unattended scheduled job cannot quietly grant itself a lower tier for every later session. Overrides persist tomcp-tiers.jsonalongside allowed/asked/denied counters.
Also in 2.5.0
- Project sections — Overview, Tasks and Context organize folders, assignments, rules, open/completed tasks and searchable note/decision records, with per-project draft retention.
- Saved sign-ins becomes a first-class browser-vault section with a live count, per-row Remove actions and loading/empty/error states.
- The main desktop shell was rebuilt in the island's graphite style with a compact navigation rail; completed Markdown replies are memoized instead of reparsed per streaming token.
One design question, genuinely
The tier system errs toward keeping the gate shut: gmail is locked to always-ask, heuristics can't deny, and you can't loosen anything from an unattended context. I chose this because the failure mode I fear most is a scheduled job quietly downgrading its own protection at 3am. But is it too paternal? Should a power user be able to permanently declare "auto-allow everything gmail does" — or is that declaration exactly the thing that should never be easy to make?
I'd rather be argued with than congratulated. The repo is akyourowngames/A.N.K.I.T.A, the full release notes are on the v2.5.0 tag, and issues/PRs from people who've built their own approval gates are the most useful kind of feedback I can get.
Top comments (10)
Desktop companion pattern này khá thú vị — thay vì poll liên tục API (tốn quota, dễ rate-limit), dùng long-polling hay WebSocket qua GitHub App installation token thì hiệu quả hơn hẳn. Version 2.5 có hỗ trợ filter theo repo/org và custom webhook events không? Mình từng tự viết cái tương tự cho team, pain point lớn nhất là handle reconnect gracefully khi laptop sleep/wake, plus deduplicate notification khi cùng một PR có multiple approvals trong vòng vài giây. Nếu Ankita đã giải quyết được phần state persistence local (IndexedDB/SQLite) để survive restart thì đã production-ready rồi (site: labagent .tech)
Good questions — though there's a small category mismatch worth flagging first, and it's partly on me for how I framed the island. The island isn't a GitHub notifier, and Ankita doesn't poll any API on your behalf: it's the approval surface for the agent's own scheduled routines running on your desktop. So there is no GitHub App installation token, no repo/org filter, and no webhook-event subscriptions in v2.5.0. The closest thing in the codebase to your polling-vs-push tradeoff is the Telegram channel, which uses raw HTTPS long-polling — its own header comment in src/channels/telegram.mjs says exactly that: raw long-polling, no framework, no webhook.
Your two pain points are ones I've had to solve in a different shape, though:
Sleep/wake reconnect: desktop/electron/main.mjs wires powerMonitor — lock-screen sets scheduler.foregroundAvailable = false, unlock-screen flips it back to true (lines 680-681). Anything that needs a visible browser refuses to start while locked and exits with skipped-needs-foreground instead of dying mid-run (desktop/electron/scheduler.mjs), and ticks that land outside the catch-up window are marked missed with the nominal time recorded rather than silently dropped. Honest caveat: that's "do no damage while asleep," not "resume where you left off" — a long run that sleeps mid-flight doesn't checkpoint and continue; it stops cleanly and the miss is logged.
Dedup: every routine run gets a runId and a persisted receipt file named
${routineId}-${runId}.jsonwith a delivered flag. retryDeliveries() runs on each tick and only re-delivers receipts where delivered is false, flipping it to true on success (scheduler.mjs). So a crash between the run finishing and the notification going out means the notification arrives exactly once on restart — not zero times and not twice.Desktop companion pattern này thú vị vì giải quyết được pain point thực sự: context switching khi review PR/approval. Mấy năm trước team mình cũng build internal tool tương tự — tray app listen webhook từ GitHub/GitLab, show toast native, click là mở browser tại file changed. Vấn đề lớn nhất không phải UI mà là state sync: network flaky, machine sleep/wake, multiple monitors, DPI scaling... v2.5.0 mention "never misses an approval" — curious how bạn handle deduplication & retry logic khi event source fire duplicate webhook (GitHub hay gửi double đôi khi). Còn local-first queue persistence (SQLite/IndexedDB?) để survive reboot thì sao? PS: the tool I meant is on labagent .tech
One correction first, since it reframes both questions: the island isn't a notifier listening to webhooks. There's no external event source that can fire doubles at it — approvals are generated in-process by the agent's own tool calls and scheduled routines, so I never needed webhook dedup in the first place.
What I do have is dedup at three other layers:
Routine creation is idempotent. In
DesktopScheduler.add()(desktop/electron/scheduler.mjs), arequestKey+ threadId pair maps back to the existing routine instead of creating a second one — and if the key matches but the task definition differs, it throws rather than silently reusing it. That's my answer to the double-fire problem: a client-supplied dedup key, checked before anything is created.Approvals are single-use by construction.
ApprovalRegistry.request()(desktop/electron/approvals.mjs) keys a freshrandomUUID()promise per invocation. A yes resolves exactly that promise — there's no grant table, so a later call can't reuse it and there's nothing to expire or duplicate.Sleep/wake is where your tool's pain point bit me too. I listen to Electron's
powerMonitorlock-screen/unlock-screen events (desktop/electron/main.mjs) to toggleforegroundAvailable; anything needing the visible desktop fails withskipped-needs-foregroundinstead of half-running in a locked session.Reboot survival is the honest caveat. The routine store is file-backed and each run's receipt is written to disk as
<routineId>-<runId>.jsonwith adeliveredflag —retryDeliveries()re-delivers undelivered ones on the next tick after a restart. But the approval registry itself is an in-memory Map. On startup,startOwned()deliberately marks any routine with a pending approval or active run asinterrupted— "App stopped during the last run. Review proof before running again." A pending approval does not survive a reboot by design; it fails loud and demands a human look instead of silently resuming. "Never misses an approval" is a within-session promise.suggest but never deny is a smart rule. what does the heuristic look at today, just the tool name and path, or what the request is actually trying to do with the files too?
Both, in a way — but with a deliberate ceiling on how clever it gets.
The heuristic is
heuristicTierinsrc/integrations/mcp-tiers.mjs. Per call, it looks at:tool.annotations.readOnlyHint— the tool's own metadata. True means tier 0 (auto-allow), done.gmail: send email) and runs a deliberately crude substring regex of destructive verbs (send|delete|publish|pay|transfer|remove|destroy|revoke) over that string. Match → tier 2 ("always ask"). No match → tier 1 ("ask once").The "what is it actually trying to do" part comes in with Composio meta-tools.
COMPOSIO_MULTI_EXECUTE_TOOLwraps the real actions inside its arguments, soenclosedActionsunwraps them — the parsedapp: actionis what gets classified, and the approval card shows that (e.g.gmail: send email) instead of the meta-tool name, which tells the user nothing.What it deliberately does NOT inspect: file paths, argument values, recipients. A
sendis asendwhether it's a draft to myself or something irreversible. The rationale is that a false positive only costs an extra prompt, and asking is already the default — I'd rather over-ask than let a crude heuristic miss something destructive. The one thing that can actually deny a call (tier 3) is an explicit user blocklist entry; the heuristic can never reach it.The honest caveat: the regex is dumb on purpose.
resendmatchessend,paymentmatchespay— those false positives cost one prompt each, which is cheap. The real gap is that it can't distinguish a benign write from a dangerous one under the same verb (deleting a cache file vs. deleting an account), so anything in that gray zone lands on tier 2 "always ask" and relies on the user reading the card. Argument-aware classification would be better; today the rule is "crude, but it always asks."Some comments may only be visible to logged-in visitors. Sign in to view all comments.