My Claw: Build Your AI System with Claude Code
Channels
Make the claw reachable from your phone. One Telegram bot token, the Vercel Chat SDK, and your claw goes from terminal-only to always-available.
The claw works. It has identity, skills, and background awareness. But you have to sit at a terminal and type node claw.mjs to talk to it. That's not an assistant. That's a command-line tool.
This lesson makes it reachable from your phone. You message it on Telegram, it reads your digest, and responds. The system goes from "I call the claw" to "the claw is reachable."
What you're building
A channel layer that connects Telegram to your claw. Messages come in via polling, get routed to claude -p, responses go back. Per-chat sessions mean the claw remembers each conversation independently.
Your phone → Telegram → Chat SDK (polling) → claw → claude -p → response → Telegram → your phoneGet a Telegram bot token
This is the only manual step. Open Telegram, find @BotFather, and:
That's 2 minutes. Save the token.
The official tutorial at core.telegram.org/bots/tutorial covers the full picture, but the Chat SDK abstracts the Telegram API. You won't touch it directly.
Install the Chat SDK
The Vercel Chat SDK is a unified interface for building bots across Slack, Discord, Telegram, WhatsApp, and more. Write your handler once, deploy to any platform by adding an adapter.
{
"name": "my-claw",
"version": "0.2.0",
"type": "module",
"dependencies": {
"chat": "latest",
"@chat-adapter/telegram": "latest",
"@chat-adapter/state-memory": "latest"
}
}Create .env:
TELEGRAM_BOT_TOKEN=your-token-from-botfatherRefactor the claw for programmatic use
The claw from the previous lesson is a CLI script. Channels need to call it as a function: send a prompt, get a response, route it back. Export an async ask(prompt, chatId) function while keeping the CLI entrypoint.
Per-chat sessions are new. Each Telegram conversation gets its own session file, so the claw remembers who said what in each chat independently.
import { spawn } from "node:child_process";
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SESSIONS_DIR = join(__dirname, ".claw", "sessions");
const DIGEST_FILE = join(__dirname, ".claw", "cache", "digest.md");
mkdirSync(SESSIONS_DIR, { recursive: true });
function sessionFile(chatId) {
const safe = String(chatId).replace(/[^a-zA-Z0-9_-]/g, "_");
return join(SESSIONS_DIR, `${safe}.session`);
}
export async function ask(prompt, chatId = "cli") {
let contextPrefix = "";
if (existsSync(DIGEST_FILE)) {
const digest = readFileSync(DIGEST_FILE, "utf-8");
contextPrefix = `[CURRENT CONTEXT — from background gatherer]\n\n${digest}\n\n---\n\nOperator message: `;
}
return _call(contextPrefix + prompt, sessionFile(chatId));
}
async function _call(fullPrompt, sFile, retried = false) {
const args = ["-p", fullPrompt, "--output-format", "json"];
const sessionId = existsSync(sFile)
? readFileSync(sFile, "utf-8").trim()
: null;
if (sessionId) args.push("--resume", sessionId);
const stdout = await new Promise((resolve, reject) => {
const proc = spawn("claude", args, {
cwd: __dirname,
stdio: ["pipe", "pipe", "pipe"],
timeout: 120000,
});
let out = "", err = "";
proc.stdout.on("data", (d) => (out += d));
proc.stderr.on("data", (d) => (err += d));
proc.on("close", (code) => {
if (code !== 0) reject(new Error(err || `claude exited ${code}`));
else resolve(out);
});
proc.on("error", reject);
});
const result = JSON.parse(stdout);
// Stale session: clear and retry once
if (result.is_error && !retried && sessionId) {
const msg = result.result || "";
if (msg.includes("session") || msg.includes("conversation")) {
console.log(`Session stale, retrying fresh`);
try { unlinkSync(sFile); } catch {}
return _call(fullPrompt, sFile, true);
}
}
if (result.is_error) throw new Error(result.result);
if (result.session_id) writeFileSync(sFile, result.session_id);
return {
text: result.result,
cost: result.total_cost_usd ?? 0,
turns: result.num_turns ?? 1,
duration: result.duration_ms ?? 0,
sessionId: result.session_id,
};
}Two things worth noting.
Session recovery: if --resume fails because a session went stale (container restart, compaction), the claw clears the session file and retries fresh. Without this, you get a permanent "Something went wrong" for that chat.
And spawn instead of execFileSync. The channel needs async. Can't block the event loop while Claude thinks for 5 seconds.
The channel
import { Chat } from "chat";
import { createTelegramAdapter } from "@chat-adapter/telegram";
import { createMemoryState } from "@chat-adapter/state-memory";
import { ask } from "./claw.mjs";
const telegram = createTelegramAdapter({
mode: "polling",
});
const bot = new Chat({
userName: "myclaw",
adapters: { telegram },
state: createMemoryState(),
onLockConflict: "force",
});
async function handleMessage(thread, message) {
const text = message.text?.trim();
if (!text) return;
console.log(`[${thread.id}] ← ${text.slice(0, 80)}`);
try {
await thread.startTyping();
const res = await ask(text, thread.id);
await thread.post(res.text);
} catch (err) {
console.error(`[${thread.id}] Error:`, err.message?.slice(0, 200));
await thread.post("Something went wrong. Check the logs.");
}
}
bot.onNewMention(async (thread, message) => {
await thread.subscribe();
await handleMessage(thread, message);
});
bot.onSubscribedMessage(async (thread, message) => {
await handleMessage(thread, message);
});
await bot.initialize();
console.log("Claw listening on Telegram (polling mode)");That's the whole channel layer. ~40 lines. The Chat SDK handles polling, message normalization, typing indicators, and deduplication.
Three things to understand.
Polling mode. mode: "polling" uses Telegram's getUpdates API. No public endpoint needed, no ngrok, no Vercel deploy. The bot pulls messages from Telegram on a loop. This is the right mode for a personal system running in Docker on your machine. When you deploy to a server with a public URL, switch to mode: "webhook" or mode: "auto" (which detects the environment automatically).
Event routing. onNewMention fires for the first message in a DM or @-mention in a group. Calling thread.subscribe() tells the SDK to route all follow-up messages in that thread to onSubscribedMessage. That's conversation continuity at the channel level. The claw's --resume handles continuity at the Claude level.
Lock conflict. onLockConflict: "force" means if you send a new message while the claw is still processing the previous one, the SDK interrupts the old handler and processes the new message. Without this, overlapping messages get dropped.
The Dockerfile
Up to now you've been running node claw.mjs directly on your machine. The channel process is long-running, needs dependencies installed, and should be isolated. Docker handles all of this.
FROM node:22-slim
RUN npm install -g @anthropic-ai/claude-code 2>&1 | tail -1
RUN npm install -g @googleworkspace/cli 2>&1 | tail -1
USER node
ENV HOME=/home/node
WORKDIR /workspace
COPY --chown=node:node package.json .
RUN npm install
COPY --chown=node:node . .
CMD ["bash"]Claude Code and gws get installed globally. Your project dependencies (Chat SDK) get installed via npm install. Everything else is COPY'd in. Don't mount your source with Docker volumes. ESM + node_modules + bind mounts is a recipe for module resolution failures. Just COPY.
run.sh
A launcher script that handles Docker args, auth setup, and subcommands. This is the entry point for everything going forward.
#!/bin/bash
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
IMAGE="my-claw"
# Build image if needed
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
echo "Building Docker image..."
docker build -t "$IMAGE" "$DIR"
fi
# Docker args: mount .claw for persistence, pass env
DOCKER_ARGS=(
docker run --rm
-v "$DIR/.claw:/workspace/.claw"
-v "$HOME/.claude:/home/node/.claude:ro"
-v "$HOME/.claude.json:/home/node/.claude.json:ro"
--env-file "$DIR/.env"
)
# Detect TTY
if [ -t 0 ]; then
DOCKER_ARGS+=(-it)
fi
case "${1:-ask}" in
ask)
shift
"${DOCKER_ARGS[@]}" "$IMAGE" node claw.mjs "$@"
;;
gather)
shift
"${DOCKER_ARGS[@]}" "$IMAGE" node gather.mjs "$@"
;;
channel)
"${DOCKER_ARGS[@]}" "$IMAGE" node channel.mjs
;;
build)
docker build -t "$IMAGE" "$DIR"
;;
*)
echo "Usage: ./run.sh [ask|gather|channel|build] [args...]"
;;
esac./run.sh channel starts the long-running Telegram bot. ./run.sh ask "question" sends a one-shot prompt. ./run.sh gather runs the background gatherer. The .claw/ directory is mounted from the host so all state persists across container restarts.
Try it
# Pre-warm the digest so the claw has context
./run.sh gather --fixture
# Start the Telegram bot
./run.sh channelYou should see:
Claw listening on Telegram (polling mode)
Runtime: polling
Send a message to your bot to test. Ctrl+C to stop.Open Telegram on your phone and message your bot: "What's on my calendar today?"
The typing indicator appears. A few seconds later, the claw responds with context from the digest, not from a live API call. The same system that worked at your terminal now works from your phone.
Model choice
The claw uses whatever model is your Claude Code default. Be explicit about this. For a channel-facing claw, response quality matters. You're reading on a small screen and want concise, accurate answers. Add --model to the args in claw.mjs if you want to pin it:
const args = ["-p", fullPrompt, "--output-format", "json", "--model", "opus"];Multi-model routing starts to make sense here. The background gatherer already uses Haiku ($0.02/cycle). The claw uses Opus or Sonnet. If you add automated tasks later, they might use a different model. Each context has different cost and quality tradeoffs. For now, being explicit is enough. Routing comes when you have enough contexts that manual selection becomes a burden.
What you have
A claw reachable from your phone. Telegram messages route through the Chat SDK, hit claude -p in Docker, and responses come back. Per-chat sessions, background context, typing indicator, stale session recovery.
Adding another channel (Discord, Slack, WhatsApp) is one adapter and a few environment variables. The Chat SDK normalizes everything. Your handleMessage function doesn't change.
Check your foundations
Before moving on, verify these. The next lessons build directly on them.
What's missing
The claw responds, but it forgets everything between container restarts. Sessions are files. They survive within a run but not across Docker rebuilds. Knowledge doesn't compound. You tell it "I prefer morning meetings" and it remembers for this session. Tomorrow, it's gone. Next: give it durable memory.