The model decision: Workers AI or your own key
Workers AI needs no key and no card. Its free allocation is 10,000 neurons a day, and Llama 3.3 70B fp8-fast is priced at 26,668 neurons per million input tokens and 204,805 per million output tokens[2]. The usage assumption below: 30 chats a day, 2,000 input tokens (system prompt plus history) and 400 output tokens per chat, over 30 days. That is 1.8M input and 0.36M output tokens a month.
Workers AI pricing is from Cloudflare's pricing page; Claude pricing is from Anthropic's, both checked 15 October 2026[2][4]. The math for Haiku: 1.8 × $0.10 plus 0.36 × $0.50 equals $0.36. For Sonnet: 1.8 × $2 plus 0.36 × $10 equals $7.20.
The practical split: start on Workers AI because it costs nothing and needs no secrets. Switch the single model call to Claude when answers need tool use or better reasoning. At $0.36 a month, Haiku 5.5 is cheaper than the time you would spend tuning a free model. To price your own workload, plug your token counts into the agent run cost simulator.
Build it: 15-minute setup
Step 1: project and dependencies
mkdir personal-agent && cd personal-agent
npm init -y
npm i agents
npm i -D wrangler typescript @cloudflare/workers-types
mkdir src
Install is a single package: npm i agents[5]. Create a Telegram bot with @BotFather and keep the token. Send your new bot any message, then open https://api.telegram.org/bot<TOKEN>/getUpdates and copy message.chat.id. That is your owner ID.
Step 2: wrangler.jsonc
{
"name": "personal-agent",
"main": "src/index.ts",
"compatibility_date": "2026-10-01",
"compatibility_flags": ["nodejs_compat"],
"ai": { "binding": "AI" },
"durable_objects": {
"bindings": [{ "name": "PersonalAgent", "class_name": "PersonalAgent" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["PersonalAgent"] }]
}
The binding name becomes the property on env, class_name must match the exported class exactly, and new_sqlite_classes is what enables SQLite storage[5].
Step 3: the agent and the webhook (src/index.ts)
import { Agent, getAgentByName } from "agents";
interface Env {
AI: Ai;
PersonalAgent: DurableObjectNamespace<PersonalAgent>;
TELEGRAM_TOKEN: string;
WEBHOOK_SECRET: string;
OWNER_CHAT_ID: string;
}
type Turn = { role: "user" | "assistant"; content: string };
const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
const SYSTEM = "You are a terse personal assistant. Answer in under 120 words.";
async function sendTelegram(env: Env, chatId: string, text: string) {
await fetch(`https://api.telegram.org/bot${env.TELEGRAM_TOKEN}/sendMessage`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ chat_id: chatId, text }),
});
}
export class PersonalAgent extends Agent<Env> {
async onStart() {
this.sql`CREATE TABLE IF NOT EXISTS memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
content TEXT NOT NULL,
ts INTEGER NOT NULL
)`;
}
async chat(chatId: string, text: string): Promise<string> {
// Cron schedules are idempotent by default, so repeating this is safe.
await this.schedule("30 3 * * *", "morningBrief", { chatId });
this.sql`INSERT INTO memory (role, content, ts) VALUES ('user', ${text}, ${Date.now()})`;
const history = this.sql<Turn>`
SELECT role, content FROM (SELECT * FROM memory ORDER BY id DESC LIMIT 12)
ORDER BY id`;
const result = (await this.env.AI.run(MODEL, {
messages: [{ role: "system", content: SYSTEM }, ...history],
})) as { response?: string };
const reply = result.response ?? "The model returned nothing.";
this.sql`INSERT INTO memory (role, content, ts) VALUES ('assistant', ${reply}, ${Date.now()})`;
return reply;
}
async morningBrief(payload: { chatId: string }) {
const notes = this.sql<{ content: string }>`
SELECT content FROM memory WHERE role = 'user' ORDER BY id DESC LIMIT 20`;
const result = (await this.env.AI.run(MODEL, {
messages: [
{ role: "system", content: "List open tasks and reminders from these notes in 5 lines max." },
{ role: "user", content: notes.map((n) => n.content).join("\n") },
],
})) as { response?: string };
await sendTelegram(this.env, payload.chatId, result.response ?? "Nothing to report.");
}
}
export default {
async fetch(request, env, ctx): Promise<Response> {
if (request.method !== "POST") return new Response("ok");
if (request.headers.get("X-Telegram-Bot-Api-Secret-Token") !== env.WEBHOOK_SECRET) {
return new Response("forbidden", { status: 403 });
}
const update = (await request.json()) as {
message?: { chat: { id: number }; text?: string };
};
const text = update.message?.text;
const chatId = String(update.message?.chat.id ?? "");
if (!text || chatId !== env.OWNER_CHAT_ID) return new Response("ok");
ctx.waitUntil(
(async () => {
const agent = await getAgentByName(env.PersonalAgent, chatId);
const reply = await agent.chat(chatId, text);
await sendTelegram(env, chatId, reply);
})(),
);
return new Response("ok");
},
} satisfies ExportedHandler<Env>;
Every piece here is a documented Agents SDK call. this.sql is a tagged template for the instance's embedded SQLite, getAgentByName(namespace, name) returns a typed stub you can call methods on, and this.schedule(when, callback, payload) takes a delay in seconds, a Date, or a cron string, with the callback given as a method name on the same class[6]. A callback name that does not exist throws, so keep the string and the method in sync.
The cron string 30 3 * * * runs in UTC. That is 9:00 AM in India. Adjust it for your timezone.
Step 4: secrets, deploy, webhook
npx wrangler secret put TELEGRAM_TOKEN
npx wrangler secret put WEBHOOK_SECRET
npx wrangler secret put OWNER_CHAT_ID
npx wrangler deploy
curl "https://api.telegram.org/bot<TOKEN>/setWebhook" \
-d "url=https://personal-agent.<your-subdomain>.workers.dev" \
-d "secret_token=<same value as WEBHOOK_SECRET>"
Telegram's secret_token accepts 1 to 256 characters from A-Z, a-z, 0-9, underscore and hyphen, and arrives in the X-Telegram-Bot-Api-Secret-Token header on every update[7]. That header check is the only thing stopping strangers from posting to your Worker URL, so do not skip it. The owner-ID check is the second lock: it means a leaked bot username cannot burn your neuron budget.
The handler returns 200 immediately and does the work inside ctx.waitUntil. Telegram retries a webhook that is slow to answer, and a 5-second model call would otherwise produce duplicate replies.
Swapping in your own Claude key
Replace the env.AI.run call with a plain fetch to the Messages API. Store the key with npx wrangler secret put ANTHROPIC_API_KEY.
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": this.env.ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-haiku-5-5",
max_tokens: 512,
system: SYSTEM,
messages: history,
}),
});
const data = (await res.json()) as { content: { type: string; text?: string }[] };
const reply = data.content.find((b) => b.type === "text")?.text ?? "No text returned.";
The Haiku 5.5 API ID is claude-haiku-5-5 and Sonnet 5.5 is claude-sonnet-5-5[8]. Add ANTHROPIC_API_KEY: string to the Env interface. The fetch wait does not count toward the 10 ms CPU cap, but parsing a large JSON response does, so keep max_tokens modest.
Where to keep memory: SQLite, D1 or KV
Start with the agent's own SQLite, as in the code above. It is per-user, free to query from inside the class, and allows 5 GB total. Use D1 only when you need one query across several agents, such as an admin view of every chat. Skip KV for conversation logs: 1,000 writes a day to different keys is about 500 chats if you store two entries per turn. Use KV for read-heavy config you rarely change.
For semantic recall over old notes, add Vectorize. The 5M stored-dimension allowance holds roughly 3,900 vectors at 1,280 dimensions each, and the 30M queried-dimension allowance covers about 23,000 queries a month at that size. A personal notebook will not hit either.
What breaks first when you outgrow free
Ordered by how soon you hit them, on the usage assumption above:
- Workers AI neurons. 10,000 a day is about 74 chats of 2,000 input and 400 output tokens on the 70B model. Long history windows eat it faster. Fix: switch to Llama 3.1 8B (about 120 chats a day), shorten history from 12 messages to 6, or move to Haiku at a few cents a week.
- Durable Object row writes. 100,000 a day. Each chat writes two memory rows plus index and schedule overhead, so you have headroom for thousands of chats. A chatty multi-user bot reaches this before requests.
- CPU time. 10 ms per request. Chat logic uses a fraction of that. It breaks when you add PDF parsing, image handling or crypto inside the request path. Move that work to a Workflow step, which has the same 10 ms free limit, or upgrade.
- Requests. 100,000 a day at roughly two requests per chat (Worker plus Durable Object) is about 50,000 chats. You will not get there alone.
When any limit trips, operations of that type start failing until midnight UTC. Your agent does not degrade gracefully, so wrap the model call in a try/catch and reply with a short "out of budget until 05:30 IST" message instead of silence. If your agent later needs to buy things or provision services on its own, the Cloudflare and Stripe agent provisioning guide covers that path.
FAQ
Is the Cloudflare Agents SDK free to use?
The agents package is an npm library with no license fee. You pay only for the platform services it runs on, and all of those have free tiers: Workers, Durable Objects (SQLite-backed) and Workers AI.
How many Telegram messages can I send per day on the free tier?
The binding constraint is Workers AI at 10,000 neurons a day, roughly 74 chats on Llama 3.3 70B with 2,000 input and 400 output tokens each. The request caps allow far more, about 50,000 chats.
Do I need a credit card for Workers AI?
No. The 10,000 daily neurons are included on the free plan. The $0.011 per 1,000 neurons overage applies only on the paid Workers plan, so on free you hit a limit and wait for the reset.
Can the agent run tasks while I am asleep?
Yes. this.schedule accepts a cron string and fires the named method on the agent without any incoming request.
Is it safe to expose the Worker URL publicly?
It is public by necessity, since Telegram must reach it. Protect it with the webhook secret header and the owner chat ID check. Never log message text to an external service, and keep API keys in wrangler secret, never in wrangler.jsonc.
Workers AI or Claude for a personal agent?
Workers AI if you want $0 and simple chat, notes and summaries. Claude Haiku 5.5 at about $0.36 a month for 30 chats a day when you need better instruction following. Sonnet 5.5 at about $7.20 a month only pays off for hard reasoning.
Sources
- Cloudflare Workers limits (accessed 15 October 2026)
- Cloudflare Workers AI pricing (accessed 15 October 2026)
- Cloudflare Durable Objects pricing and free plan quotas (accessed 15 October 2026)
- Anthropic Claude API pricing (accessed 15 October 2026)
- Cloudflare Agents: add to an existing project (accessed 15 October 2026)
- Cloudflare Agents: schedule tasks (accessed 15 October 2026)
- Telegram Bot API: setWebhook (accessed 15 October 2026)
- Anthropic models overview (accessed 15 October 2026)
Every product mentioned is available at wowhow.cloud — pay once, ship forever.
Comments · 0
Beta: comments are stored locally on your device and not visible to other readers.
No comments yet. Be the first to share your thoughts.