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

Grok bot
Finds a news story and sends it in.
Mirai drafts
Reads the source, writes the post.
Card in Telegram
Approve, Edit or Skip.
Pick destinations
Groups, topics, website.
Posted
Each tap posts right away.
Code, no modelModel at work
  • 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.

04DoorWorker route: Telegram, API, cron
01HomeDurable Object, one per conversation, with its own SQLite
02BrainPiHarness + Pi Durable + a model
03HandsTools: name, description, schema, function
  1. 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.

    JSONC
    // wrangler.jsonc
    "durable_objects": { "bindings": [{ "name": "CHAT", "class_name": "MyAgent" }] },
    "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyAgent"] }]
  2. A brain: Pi Durable through PiHarness

    PiHarness runs 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.

    TS
    // 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);
    }
  3. 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.

    TS
    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) }] };
      },
    };
  4. 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().

    TS
    // 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}` });
  5. Ship it

    Keys go in Doppler and reach the Worker with wrangler secret. Deploy with wrangler 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.