Building a Personal AI Agent That Lives in Telegram and Thinks in Notion

What is not-claw?

not-claw is a personal AI agent you talk to on Telegram. Its entire brain, identity, memory, skills, and task queue, lives in a Notion workspace accessed through the Notion MCP server. You and the agent are equal participants in the same workspace: you can add tasks directly in Notion, edit the agent’s memory to correct it, or write a new skill page that the agent picks up on its next run.

The project is inspired by OpenClaw, an open-source personal agent framework that stores state as Markdown files on disk. not-claw replaces that flat-file layer with Notion, and the architecture maps directly:

OpenClaw not-claw (via Notion MCP)
SOUL.md Soul page, agent identity and personality
MEMORY.md Memory page, long-term facts
skills/ directory Skills database, each page is one skill
Task queue Tasks database, status, priority, notes
Heartbeat log Heartbeat database, record of every proactive run

The difference that matters most is accessibility. Notion is visual, collaborative, and available from any device. You can open the Skills database on your phone, write a new instruction, and the agent uses it on its next session. Nothing is locked behind a CLI or a proprietary format.


Architecture

You (Telegram)
      |
      v
 +-----------+     MCP (stdio)     +---------------------+
 |  Gateway   | ----------------->  |  Notion MCP Server  |
 | gateway.js |     Agent loop      | @notionhq/          |
 +-----------+  <-  agent.js  ->    |  notion-mcp-server   |
      ^                             +---------+-----------+
      |                                       |
 +-----------+                                v
 | Heartbeat |  <- node-cron         Notion Workspace
 |heartbeat.js|   (every 30 min)     - Soul page
 +-----------+                       - Memory page
                                     - Skills DB
                                     - Tasks DB
                                     - Heartbeat log

Five files, each with a single responsibility:

File What it does
mcp-client.js Spawns the Notion MCP server as a stdio subprocess, discovers tools at startup, bridges tool calls between Claude and Notion
agent.js Agentic loop, sends messages to Claude with MCP tools, executes tool calls, feeds results back, repeats until done
gateway.js Telegram bot (grammy), relays messages between the owner and the agent
heartbeat.js Cron job that wakes the agent every 30 minutes to work the task queue
index.js Entry point, boots gateway and heartbeat together

Connecting to Notion MCP

At startup, mcp-client.js spawns the official @notionhq/notion-mcp-server as a stdio subprocess:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const transport = new StdioClientTransport({
  command: "npx",
  args: ["-y", "@notionhq/notion-mcp-server"],
  env: {
    ...process.env,
    OPENAPI_MCP_HEADERS: JSON.stringify({
      Authorization: `Bearer ${process.env.NOTION_API_KEY}`,
      "Notion-Version": "2022-06-28",
    }),
  },
});

const client = new Client({ name: "not-claw", version: "1.0.0" });
await client.connect(transport);
const { tools } = await client.listTools();
// → 22 tools discovered

These tools are converted to Anthropic tool-use format, filtered down to the 8 the agent actually needs, and passed to Claude. Claude decides which tools to call, in what order, with what arguments. The MCP client executes them and returns results, and the agent code is just the loop that ties it together.


The Agentic Loop

The loop in agent.js stays short because MCP handles the complexity:

export async function runAgent(prompt, mode = "interactive") {
  const model = mode === "heartbeat" ? MODEL_HEARTBEAT : MODEL_INTERACTIVE;

  await connectMcp();
  const tools = await getFilteredTools();
  const soulContent = await getSoulContent();

  const systemPrompt = buildSystemPrompt(mode, soulContent);
  const messages = [{ role: "user", content: prompt }];

  for (let turn = 0; turn < MAX_TURNS; turn++) {
    const response = await client.messages.create({
      model,
      max_tokens: 4096,
      system: systemPrompt,
      tools,
      messages,
    });

    messages.push({ role: "assistant", content: response.content });

    if (response.stop_reason === "end_turn") {
      return response.content
        .filter((b) => b.type === "text")
        .map((b) => b.text)
        .join("\n");
    }

    if (response.stop_reason === "tool_use") {
      const toolResults = [];
      for (const block of response.content) {
        if (block.type !== "tool_use") continue;
        const result = await callTool(block.name, block.input);
        toolResults.push({
          type: "tool_result",
          tool_use_id: block.id,
          content: result,
        });
      }
      messages.push({ role: "user", content: toolResults });
    }
  }
}
  1. Connect to Notion MCP, discover tools
  2. Send user message + tools to Claude
  3. If Claude calls tools, execute via MCP client, feed results back, repeat
  4. If Claude returns text, send to user via Telegram

Claude typically makes 3-8 tool calls per interaction: read Soul, read Memory, search Skills, then do whatever the user asked.


The Heartbeat

The heartbeat is what separates not-claw from a regular chatbot. Every 30 minutes, a cron job wakes the agent to work through pending tasks without being asked, then messages you the result on Telegram.

async function runHeartbeat() {
  if (heartbeatRunning) return;
  heartbeatRunning = true;

  // Pre-check: skip full agent run if no pending tasks
  const hasTasks = await checkPendingTasks();
  if (!hasTasks) {
    heartbeatRunning = false;
    return;
  }

  const result = await runAgent(heartbeatPrompt, "heartbeat");

  if (!result.includes("💤")) {
    await bot.api.sendMessage(OWNER_ID, `*💓 Heartbeat*\n\n${result}`, {
      parse_mode: "Markdown",
    });
  }

  heartbeatRunning = false;
}

cron.schedule("*/30 * * * *", runHeartbeat);

On each run, the heartbeat reads Memory for context, queries the Tasks database for pending work, picks the highest-priority task, does the work, updates the task status in Notion, and logs the run to the Heartbeat database. Interactive messages use Sonnet for quality, while heartbeats use Haiku at roughly 1/60th the cost. Both models are configurable via environment variables.


Cost Optimizations

Running an agent on a cron schedule means API costs add up fast without guardrails. Three optimizations keep costs low.

Heartbeat pre-check. Before spinning up a full Claude session, the heartbeat queries the Tasks database directly through MCP. If there are no pending or in-progress tasks, it skips the Claude API call entirely, so idle heartbeats cost nothing.

export async function checkPendingTasks() {
  await connectMcp();
  const result = await callTool("API-post-search", {
    body: JSON.stringify({
      filter: { property: "object", value: "page" },
      query: "",
    }),
  });

  const data = JSON.parse(result);
  const pendingTasks = (data.results || []).filter((page) => {
    if (page.parent?.database_id?.replace(/-/g, "") !== tasksDbId.replace(/-/g, ""))
      return false;
    const status = page.properties?.Status?.select?.name;
    return status === "pending" || status === "in-progress";
  });

  return pendingTasks.length > 0;
}

Soul caching. The Soul page defines the agent’s identity and rarely changes. Instead of reading it via a tool call on every session, the agent pre-fetches it once and caches it in memory with a 1-hour TTL. The cached content is injected directly into the system prompt, saving one tool call per run.

Tool filtering. The Notion MCP server exposes 22 tools, but the agent only uses 8 of them. Sending all 22 tool definitions inflates the input token count on every API call, so the MCP client filters down to just the ones the agent needs, saving roughly 1,000 input tokens per call.

const ALLOWED_TOOLS = [
  "API-retrieve-a-page",
  "API-get-block-children",
  "API-patch-block-children",
  "API-post-search",
  "API-retrieve-a-database",
  "API-post-page",
  "API-patch-page",
  "API-retrieve-a-page-property",
];

export async function getFilteredTools() {
  const allTools = await getTools();
  return allTools.filter((t) => ALLOWED_TOOLS.includes(t.name));
}

Self-Improving Skills

Tell the agent to learn something and it writes a new skill page to the Skills database. The instructions are stored as a Notion page with a title and body content. Future sessions search the Skills database before attempting non-trivial tasks and follow whatever instructions they find.

You:   Learn how to summarize a webpage and save it as a skill
Agent: Done, I've added "Summarize webpage" to your Skills database.

The system prompt makes the expectation explicit:

- Skills database (ID: ${ids.skills})
  Each page = one skill. Title = skill name. Body = instructions.
  Search here when asked to do something. Write NEW skills back here
  when you figure out how to do something new — this is how you improve.

The agent teaches itself by writing to Notion, and you can inspect, edit, or delete any skill page directly.


Two-Way Collaboration

Most chatbots are one-directional: you talk to the bot, the bot talks back. Because not-claw stores everything in Notion, the relationship is collaborative. Add a task directly in the Tasks database and the heartbeat picks it up. Edit the Memory page to correct something the agent got wrong. Write a skill page with specific instructions and the agent follows them next session. Every heartbeat run is logged, so you can see exactly what happened and when.

The data isn’t locked behind the bot. Notion is the shared workspace, and you and the agent both read and write to it.

Because Notion has built-in sharing, a whole team can have access to the agent’s pages and tables. Everyone can see pending tasks, review heartbeat logs, add skills, or correct the agent’s memory from their own Notion account. OpenClaw stores state as Markdown files on a single machine, so getting that kind of visibility requires SSHing in or building a separate dashboard. With Notion, the dashboard is the data.


The System Prompt

The system prompt is the most important piece of the agent. It tells Claude who it is, what tools are available, and how to use them. The structure breaks down into six sections:

  1. Identity and time. The agent’s name, the current timestamp in the owner’s timezone, and a reminder to treat it as the source of truth for date math.
  2. Notion workspace map. Page and database IDs for Soul, Memory, Skills, Tasks, and Heartbeat log, with descriptions of each schema.
  3. Tool usage notes. Workarounds for MCP server quirks, like avoiding API-query-data-source which targets a newer Notion endpoint that doesn’t work with internal integrations.
  4. Pre-loaded Soul content. If the Soul page is cached, it gets injected directly into the prompt so Claude doesn’t spend a tool call fetching it.
  5. Mode-specific instructions. Interactive mode gets instructions for responding to the user. Heartbeat mode gets a step-by-step checklist: read Memory, query Tasks, work the highest-priority one, update status, log the run.
  6. Rules. Always read Memory, search Skills before non-trivial tasks, log progress, save new skills when learned.

The prompt is long and specific because it needs to be. Claude has 22 Notion tools available (filtered to 8) and five different Notion objects to interact with. Without clear instructions, it guesses at database schemas, uses the wrong tool, or skips reading Memory entirely.


Notion MCP Quirks

Two things to know if you build on top of the Notion MCP server:

API-query-data-source doesn’t work with internal integrations. The tool targets Notion’s newer /v1/data_sources/ endpoint, which only works with OAuth-based public integrations. If you’re using a standard internal integration (which most developers are), the tool returns errors. The system prompt steers Claude toward API-post-search and API-retrieve-a-database instead, which work reliably.

API-post-page expects a JSON object for parent, not a string. Claude sometimes serializes the parent field as a string. The system prompt includes an explicit example of the correct format to prevent this.


Stack

Component Technology
Reasoning Anthropic SDK (Sonnet for interactive, Haiku for heartbeats)
Notion access @notionhq/notion-mcp-server via MCP stdio transport
MCP client @modelcontextprotocol/sdk
Telegram bot grammy
Scheduling node-cron
Config dotenv

Running It

git clone https://github.com/grzetich/not-claw
cd not-claw
npm install

Follow NOTION_SETUP.md to create your Notion workspace: Soul page, Memory page, Skills DB, Tasks DB, and Heartbeat log. Share all five with your integration.

Create a .env with your API keys, Telegram bot token, Notion page/database IDs, timezone, and model preferences.

npm start              # Gateway + heartbeat
npm run heartbeat      # One-shot heartbeat test
npm run gateway        # Gateway only (no heartbeat)

MIT license. GitHub repo.