Skip to main content

Overview

NanoClaw processes messages through a polling-based architecture that connects messaging channels to isolated Claude agent containers. Each group has its own message queue, session state, and isolated filesystem.

Message flow

1

Message arrives

Incoming messages are stored in SQLite with metadata (sender, timestamp, chat JID)
2

Trigger detection

NanoClaw checks if the message contains the trigger pattern (default: @Andy)
3

Context gathering

All messages since the last agent response are gathered for context
4

Agent invocation

Messages are formatted and sent to a Claude agent running in an isolated container
5

Response routing

The agent’s response is stripped of internal tags and sent back to the channel

Trigger patterns

Trigger patterns determine when the agent should respond. The default trigger is @{ASSISTANT_NAME} at the start of a message.
// From src/config.ts
export const TRIGGER_PATTERN = new RegExp(
  `^@${escapeRegex(ASSISTANT_NAME)}\\b`,
  'i',
);

Usage examples

@Andy what's on my calendar today?
@Andy search for recent AI developments
@ANDY help me debug this error (case-insensitive)
The main channel (your self-chat) doesn’t require a trigger - every message is processed automatically.

Message formatting

Messages are formatted as XML before being sent to the agent, preserving sender information and timestamps:
// From src/router.ts
function formatMessages(
  messages: NewMessage[],
  timezone: string,
): string {
  const lines = messages.map((m) => {
    const displayTime = formatLocalTime(m.timestamp, timezone);
    return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}">${escapeXml(m.content)}</message>`;
  });
  const header = `<context timezone="${escapeXml(timezone)}" />\n`;
  return `${header}<messages>\n${lines.join('\n')}\n</messages>`;
}

Example formatted output

<context timezone="America/Los_Angeles" />
<messages>
<message sender="Alice" time="2026-02-28 10:30 AM">@Andy what's the weather?</message>
<message sender="Bob" time="2026-02-28 10:31 AM">I think it's sunny</message>
<message sender="Alice" time="2026-02-28 10:32 AM">Can you check?</message>
</messages>

Internal tags

Agents can use <internal>...</internal> tags for reasoning that won’t be sent to users:
// From src/router.ts
export function stripInternalTags(text: string): string {
  return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}

Agent output example

<internal>
User asked about weather. I should check the forecast API.
</internal>

The weather today is sunny with a high of 75°F.
Only the visible text is sent to the user.

Group message queues

NanoClaw uses a per-group queue system with global concurrency limits to prevent resource exhaustion:
// From src/config.ts
export const MAX_CONCURRENT_CONTAINERS = Math.max(
  1,
  parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,
);

How it works

  • Each group gets its own message queue
  • Multiple groups can have active containers simultaneously (up to MAX_CONCURRENT_CONTAINERS)
  • When a container is idle, new messages are sent via IPC files without spawning a new container
  • Idle timeout: 30 minutes by default (configurable via IDLE_TIMEOUT)
When an agent container is already running and idle, NanoClaw writes follow-up messages as JSON files to the group’s IPC input directory instead of spawning a new container:
// From the message loop in src/index.ts
if (queue.sendMessage(chatJid, formatted)) {
  logger.debug(
    { chatJid, count: messagesToSend.length },
    'Piped messages to active container',
  );
  lastAgentTimestamp[chatJid] =
    messagesToSend[messagesToSend.length - 1].timestamp;
  saveState();
  channel
    .setTyping?.(chatJid, true)
    ?.catch((err) =>
      logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
    );
} else {
  queue.enqueueMessageCheck(chatJid);
}
This optimization reduces latency and container churn for active conversations.

Channel routing

NanoClaw supports multiple messaging channels through a unified Channel interface:
// From src/router.ts
export function routeOutbound(
  channels: Channel[],
  jid: string,
  text: string,
): Promise<void> {
  const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected());
  if (!channel) throw new Error(`No channel for JID: ${jid}`);
  return channel.sendMessage(jid, text);
}

Supported channels

  • WhatsApp (via /add-whatsapp skill)
  • Telegram (via /add-telegram skill)
  • Discord (via /add-discord skill)
  • Slack (via /add-slack skill)
  • Gmail (via /add-gmail skill)
Channels are added via skills, not configuration files. Use /add-telegram or similar skills to add new channels. See the integrations overview.

Message persistence

All messages are stored in SQLite (store/messages.db) with full history:
// From src/db.ts
function storeMessage(msg: NewMessage): void {
  db.prepare(
    `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
  ).run(msg.id, msg.chat_jid, msg.sender, msg.sender_name, msg.content, msg.timestamp, msg.is_from_me ? 1 : 0, msg.is_bot_message ? 1 : 0);
}

Message retrieval

// Get messages since a specific timestamp (default limit: 200)
const messages = getMessagesSince(
  chatJid,
  lastTimestamp,
  ASSISTANT_NAME
);
Both getNewMessages and getMessagesSince cap results at 200 messages by default. The agent only sees the most recent 200 messages per invocation — older messages remain in the database but are not included in the agent’s prompt. This prevents unbounded result sets and out-of-memory crashes in active groups.

Configuration

Polling interval

// From src/config.ts
export const POLL_INTERVAL = 2000; // 2 seconds
Messages are polled every 2 seconds by default. This can be adjusted by modifying the source code.

Trigger customization

To change the trigger word, ask Claude Code to modify it:
Change the trigger word to @Bob
This updates ASSISTANT_NAME in src/config.ts and .env.

Group registration

Groups must be registered before the agent can respond. The main channel can register groups via IPC:
// From src/ipc.ts — register_group handler (simplified)
case 'register_group':
  if (!isMain) {
    logger.warn({ sourceGroup }, 'Unauthorized register_group attempt blocked');
    break;
  }
  if (!isValidGroupFolder(data.folder)) {
    logger.warn({ folder: data.folder }, 'Invalid group folder name');
    break;
  }
  if (data.jid && data.name && data.folder && data.trigger) {
    // Defense in depth: isMain is never set via IPC
    deps.registerGroup(data.jid, {
      name: data.name,
      folder: data.folder,
      trigger: data.trigger,
      added_at: new Date().toISOString(),
      containerConfig: data.containerConfig,
      requiresTrigger: data.requiresTrigger,
    });
  }

Example

From the main channel:
@Andy join the Family Chat group
The agent will discover available groups and register to the one you specify.

Session commands

Session commands are slash commands sent as messages that the host process intercepts before the agent sees them. They control the agent session itself rather than asking the agent to do something.

/compact

The /compact command triggers manual context compaction to fight “context rot” in long-running sessions. When an agent has been active for a while, its context window fills up with old messages that may no longer be relevant. How to use it:
@Andy /compact
What happens:
  1. The full session transcript is archived (non-destructive — nothing is lost)
  2. Claude Agent SDK’s built-in /compact command runs, summarizing the conversation context
  3. The session continues with a condensed context, freeing space for new messages
Authorization:
SenderAllowed?
Main group (any sender)Yes
Admin/trusted sender (is_from_me) in any groupYes
Other senders in non-main groupsNo — receives “Session commands require admin access”
The /compact command is installed via the skill/compact branch. It’s not available on main by default. Apply it with:
git fetch upstream skill/compact
git merge upstream/skill/compact
The implementation lives in src/session-commands.ts, which extracts commands from messages, validates authorization, and coordinates with the container runner.
Last modified on March 23, 2026