Mirai
A Telegram agent that turns AI news into posts I approve. Here's what it does, and how I build agents like it.
What Mirai does
- Drafts posts from news. A Grok bot sends stories in; Mirai reads the source and writes a ready-to-publish post.
- Asks before anything goes out. Every draft arrives as a review card. I reply to change it, or approve it and tap where it should be posted.
- Helps with the newsletter. It searches every stored story and drafts the biweekly MasterAI newsletter.
- Makes images. Headers, carousel slides and edits, in the MasterAI style, at the right size for each platform.
How I make agents
Every agent I build is four pieces: a home, a brain, hands and a door. Mirai runs on Cloudflare with Pi Durable, and these are the same four pieces in about 1,200 lines. Start with one tool and one channel, then grow it.
-
A home: one Durable Object per conversation
A Durable Object is a tiny server with its own SQLite database. Name it after the chat (
getByName(chatId)) and every conversation gets its own memory, isolated from the rest.// wrangler.jsonc "durable_objects": { "bindings": [{ "name": "CHAT", "class_name": "MyAgent" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyAgent"] }] -
A brain: Pi Durable through PiHarness
PiHarnessruns the agent loop (model call, tool calls, next model call) and saves every step before showing it. If Cloudflare restarts the object mid-answer, the run continues where it stopped. You pick the model; I use OpenRouter.// bun add agents @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord export class MyAgent extends DurableObject<Env> { registry = createRegistry(); harness = new PiHarness({ harness: ({ storage, context, onWake }) => { this.registry.install({ name: 'mine', sections: [prompt], tools: [getNews] }); const models = createModels(); models.setProvider(openrouterProvider()); return Harness.open(storage, { models, registry: this.registry, onWake }, context); }, defaults: { model: { provider: 'openrouter', id: '~anthropic/claude-fable-latest' } }, }); lifecycle = Lifecycle.install(this).use(this.harness); } -
Hands: tools
A tool is a name, a one-line description, a typed input schema and a function. The model reads the description to decide when to call it, so write it for the model. Mark a tool
replay: 'safe'only if running it twice does no harm; anything that posts or spends money stays unsafe.const getNews: ToolRegistration<typeof Input> = { name: 'get_news', description: 'Stored AI news stories, newest first.', parameters: Type.Object({ sinceDaysAgo: Type.Optional(Type.Number()) }), replay: 'safe', async execute({ sinceDaysAgo }) { const rows = await db.listNews({ sinceDaysAgo }); return { output: [{ type: 'text', text: JSON.stringify(rows) }] }; }, }; -
A door: how messages get in and answers get out
A Worker route receives the message (a Telegram webhook, an API call) and hands it to the right Durable Object. Use the incoming event's id as the operation id, so a retried webhook never runs twice. Answers stream back from
session.events().// Worker: POST /telegram const chat = env.CHAT.getByName(String(chatId)); await chat.receive(text, update.update_id); // Inside MyAgent await this.harness.submit(text, { operationId: `tg:${updateId}` }); -
Ship it
Keys go in Doppler and reach the Worker with
wrangler secret. Deploy withwrangler deploy, test with a fake event, then point the real channel at the Worker URL.
The rule I follow most: anything plain code can do stays in code. Mirai's buttons, permissions and posting never touch the model, so they're instant, free and predictable. The model gets the judgment work: reading, writing, choosing.