Skip to content

Instantly share code, notes, and snippets.

@nodir-t
Created February 9, 2026 05:25
Show Gist options
  • Select an option

  • Save nodir-t/d8716906e93d05fd697bbf918d0f85f4 to your computer and use it in GitHub Desktop.

Select an option

Save nodir-t/d8716906e93d05fd697bbf918d0f85f4 to your computer and use it in GitHub Desktop.
Claudir Architecture — Part 6: Focus Mode

Part 6: Focus Mode

The Problem

A bot that monitors multiple chats simultaneously has a fundamental UX issue: context fragmentation. If someone is having a deep conversation in a DM while group messages arrive, the bot's attention splits. Claude sees interleaved messages from different conversations, leading to confused responses, topic mixing, and wasted context window space.

Focus mode solves this by implementing single-chat attention — the bot concentrates on one conversation at a time, queueing messages from all other chats for later processing.

How It Works

Focus mode is controlled by a focus_enabled: true config flag and backed by two SQLite tables:

  • focus_state — A singleton table (enforced by CHECK (id = 1)) that holds the currently focused chat_id, or NULL for "process all chats"
  • focus_chats — Per-chat cursor tracking. Each row stores a cursor_message_id (the last message the bot has "seen" in that chat), plus debounce metadata

When focus is active, the message handling flow changes. Every message is always saved to the database first (via add_message) — nothing is ever lost. The focus decision only controls whether the message also gets forwarded to Claude for processing:

Message arrives → saved to DB (always)
  |
  v
Is sender Tier 0? (Owner/Queen)
  |
  +-- YES --> Forward to Claude (Tier 0 bypasses focus)
  +-- NO  --> Is this the focused chat?
                |
                +-- YES --> Forward to Claude, update cursor
                +-- NO  --> Don't forward (queued for later)

Tier 0 bypass is checked in both DM and group message handlers via TIER0_USER_IDS.contains(). This ensures the owner and Queen can always reach the bot regardless of focus state.

The Cursor System

Each chat tracked in focus_chats has a cursor_message_id — the last message_id the bot has processed. Messages with IDs above the cursor are "pending." When the bot switches focus to a chat, it:

  1. Updates the cursor for the old focused chat to its latest message (marking everything as read)
  2. Queries pending messages for the new focused chat (everything above its cursor)
  3. Returns those pending messages in XML format as part of the switch_focus tool response
  4. Updates the new chat's cursor to the max returned message_id

The pending message query uses a window function for atomic counting:

WITH all_pending AS (
    SELECT *, COUNT(*) OVER() as total_pending
    FROM messages WHERE chat_id = ? AND message_id > ?
)
SELECT * FROM (
    SELECT * FROM all_pending ORDER BY timestamp DESC LIMIT ?
) ORDER BY timestamp ASC

This returns the N most recent pending messages while also reporting the total count — so the bot knows "showing 50 of 127 pending messages."

Debouncing and Injection

To prevent the bot from repeatedly re-injecting the same queued messages, focus_chats tracks:

  • last_injected_at — timestamp of last injection
  • last_pending_count — message count at last injection

Injection only triggers when either 60 seconds have elapsed OR new messages have arrived (content changed). This prevents wasted context window space from redundant notifications.

Stop-Rejection: Keeping the Bot Honest

A subtle problem: when focus is active and other chats have queued messages, the bot might try to stop (go idle) without addressing them. The engine intercepts this:

When the bot sends a stop control action and there are queued messages, the engine rejects the stop up to 3 times, injecting a warning: "You have queued messages in these chats. Consider switching focus or checking them." After 3 rejections, the stop is allowed through to prevent infinite loops.

A similar mechanism handles idle sleep cycles — after 5 consecutive idle sleeps with queued messages, the engine alerts the bot.

Tools

Focus mode exposes five MCP tools:

  • switch_focus — Set focus to a chat (by ID or alias), or clear focus. Returns pending messages as XML
  • get_focus_state — Show current focus and list all queued chats with pending counts
  • peek_chat — Read recent messages from a chat without switching focus
  • list_queued_chats — List all non-focused chats that have pending messages
  • set_chat_alias / remove_chat_alias — Map human-friendly names (like "bot_xona") to chat IDs

Chat aliases make focus switching ergonomic — switch_focus(name: "bot_xona") instead of switch_focus(chat_id: -1003648834056).

Muting vs Focus

Focus and muting are related but distinct:

  • Focus mode queues messages for later — the bot will eventually process them when focus shifts
  • Mute (mute_chat / mute_dms) silences a chat for a specified duration — messages are saved to the DB but not injected into context

Both save messages to the database (so nothing is lost), but muted messages don't trigger the queued-message warnings that focus-queued messages do.

Muting is always time-bounded: mute_chat(chat_id, duration_minutes) sets a muted_until timestamp. The health monitor auto-unmutes when the time expires. The bot can also manually unmute early via unmute_chat / unmute_dms. While muted, stats accumulate (messages received, unique users) and are reported in a summary when unmuting — so the bot knows what it missed.


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment