Agent Recipes

Once you’ve read Authoring Agents, the fastest way to build is to start from a working pattern. These recipes are short, complete, and meant to be edited.

1. Daily Telegram digest

Fires once a day at a user-configurable time, composes a message with the AI, sends it through Telegram. The canonical “do something every morning” plugin.

// manifest.json
{
  "id": "morning-ping",
  "name": "Morning Ping",
  "version": "1.0.0",
  "description": "A daily personalised greeting via Telegram.",
  "enabled": true,
  "apiPrefix": "/api/morning-ping",
  "permissions": ["telegram:send", "ai:gemini"],
  "uses": ["telegram", "ai"],
  "config": {
    "enabled": { "type": "boolean", "default": true, "label": "Enable morning ping" },
    "fireAt":  { "type": "string",  "default": "07:30", "label": "Fire time (HH:MM, local)" },
    "tone":    { "type": "select",  "default": "warm",  "label": "Greeting tone",
                 "options": [
                   { "value": "warm", "label": "Warm" },
                   { "value": "dry",  "label": "Dry-witted" },
                   { "value": "stern","label": "Stern" }
                 ]}
  }
}
// index.js
'use strict';

const { log } = require('../../core/utils/logger');

module.exports = {
  id: 'morning-ping',

  init(ctx) {
    log('[morning-ping] init');

    ctx.schedule.dailyAt(ctx.pluginConfig.fireAt, async () => {
      if (!ctx.pluginConfig.enabled) return;
      if (!ctx.services.ai.isReady() || !ctx.services.telegram.isReady()) return;

      const message = await ctx.services.ai.generate(
        `Write a one-sentence ${ctx.pluginConfig.tone} good-morning greeting to me.`,
      );
      await ctx.services.telegram.send(
        ctx.config.TELEGRAM_ALLOWED_USER_ID,
        message,
      );
    }, {
      timezone: 'America/Los_Angeles',
      tickId: 'morning-ping',
    });
  },
};

Why it works: ctx.pluginConfig.fireAt is read at schedule registration, so the user’s config value picks up at install. isReady() guards mean the plugin keeps running gracefully even when the user hasn’t configured the LLM or Telegram, or when they’ve revoked one via the Permissions tab.

2. Cross-plugin morning digest

The motivating use case for ctx.data.read — read several other plugins’ tables, compose a digest, deliver it. Declare the reads in manifest.dataReads and the loader auto-grants them on install.

// manifest.json
{
  "id": "morning-digest",
  "name": "Morning Digest",
  "version": "1.0.0",
  "description": "Aggregates weather, news, and markets into a single morning briefing.",
  "enabled": true,
  "permissions": ["telegram:send", "ai:gemini"],
  "uses": ["telegram", "ai"],
  "dataReads": [
    "weather/forecast",
    "news/headlines",
    "polymarket/markets"
  ],
  "config": {
    "fireAt": { "type": "string", "default": "07:30", "label": "Fire time" }
  }
}
// index.js
init(ctx) {
  ctx.schedule.dailyAt(ctx.pluginConfig.fireAt, async () => {
    const forecast  = ctx.data.read('weather', 'forecast');
    const headlines = ctx.data.read('news', 'headlines');
    const markets   = ctx.data.read('polymarket', 'markets');

    // ctx.data.read returns [] on any failure — denied grant,
    // missing target, missing file, malformed JSON. Treat empty
    // arrays as "not available" and degrade.
    if (!forecast.length && !headlines.length && !markets.length) {
      return;
    }

    const summary = await ctx.services.ai.generate(`
      Compose a 4-sentence morning digest. Be specific; cite numbers.
      WEATHER: ${JSON.stringify(forecast.slice(0, 1))}
      NEWS: ${JSON.stringify(headlines.slice(0, 5))}
      MARKETS: ${JSON.stringify(markets.slice(0, 3))}
    `);

    if (ctx.services.telegram.isReady()) {
      await ctx.services.telegram.send(
        ctx.config.TELEGRAM_ALLOWED_USER_ID,
        summary,
        { parse_mode: 'Markdown' },
      );
    }
  }, { timezone: 'America/Los_Angeles', tickId: 'morning-digest' });
}

Loader ordering is automatic — declaring dataReads: ["weather/forecast"] makes the loader initialise this plugin AFTER weather. You don’t need loadAfter: ["weather"].

Cross-plugin reads are read-only. If you need another plugin to do something (not just read its data), use ctx.events.emit('your-plugin:topic', payload) and have the sibling plugin subscribe in its init().

3. Ad-hoc Telegram slash command

No schedule — the plugin only does something when the user types /sayhi in Telegram.

// manifest.json
{
  "id": "sayhi",
  "name": "Sayhi Command",
  "version": "1.0.0",
  "description": "Adds a /sayhi Telegram command that replies with a greeting.",
  "enabled": true,
  "permissions": ["telegram:send"],
  "uses": ["telegram"],
  "commands": [
    { "id": "sayhi", "label": "Say hi", "args": [] }
  ]
}
// index.js
init(ctx) {
  if (typeof ctx.fn.registerTelegramCommand !== 'function') return;

  ctx.fn.registerTelegramCommand('sayhi', async (chatId) => {
    await ctx.services.telegram.send(chatId, 'Hi! 👋');
  }, { description: 'Reply with a friendly greeting.', section: 'Personal' });
},

The commands array in the manifest surfaces the command in the Telegram Mini App’s Commands tab too — same handler, two entry points. args is empty for a button-only command; add [{ name: 'amount', type: 'string', label: 'Amount', required: false }] to expose an input field in the Mini App.

4. Polling an external API on an interval

Fetches data every N minutes, persists to the typed store, exposes via an HTTP route for a custom panel.

// manifest.json
{
  "id": "stocks",
  "name": "Stock Watch",
  "version": "1.0.0",
  "description": "Polls a stock-price API every 10 minutes.",
  "enabled": true,
  "apiPrefix": "/api/stocks",
  "permissions": ["state:read", "state:write"],
  "uses": [],
  "config": {
    "tickerList": { "type": "string", "default": "GOOGL,MSFT,AAPL",
                    "label": "Tickers (comma-separated)" },
    "intervalSec": { "type": "number", "default": 600,
                     "label": "Poll interval (seconds)" }
  }
}
// store/schema.js
'use strict';

module.exports = {
  schema: {
    quotes: {
      primaryKey: 'symbol',
      fields: {
        symbol: 'string',
        price:  'number',
        delta:  'number',
        at:     'string',
      },
    },
  },
};
// index.js
'use strict';

const { log } = require('../../core/utils/logger');

module.exports = {
  id: 'stocks',

  init(ctx) {
    const intervalMs = (ctx.pluginConfig.intervalSec || 600) * 1000;
    ctx.schedule.every(intervalMs, async () => {
      const tickers = String(ctx.pluginConfig.tickerList || '').split(',').map(s => s.trim()).filter(Boolean);
      for (const symbol of tickers) {
        try {
          const res = await fetch(`https://your-quotes-api.example/v1/${symbol}`);
          if (!res.ok) continue;
          const { price, delta } = await res.json();
          await ctx.store.set('quotes', {
            symbol,
            price,
            delta,
            at: new Date().toISOString(),
          });
        } catch (err) {
          log(`[stocks] ${symbol}: ${err.message}`);
        }
      }
    }, { tickId: 'stocks', runImmediately: true });
  },

  routes(router, ctx) {
    router.get('/api/stocks/quotes', async ({ res }) => {
      const rows = await ctx.store.get('quotes');
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ quotes: rows }));
    });
  },
};

Why use ctx.store: every quote write lands in data/quotes.json atomically, so a crash mid-write never produces a half-saved file. Other plugins can also read your quotes via ctx.data.read('stocks', 'quotes') if they declare it in their manifest.dataReads.

5. Reactive plugin — no schedule, only events

Subscribe to events emitted by another plugin and react to them. Pair with ctx.events.emit(...) in the publisher plugin.

// index.js
init(ctx) {
  ctx.events.on('reminders:created', async (payload, ctxRef) => {
    // payload is whatever the publisher sent
    if (!ctx.services.telegram.isReady()) return;
    await ctx.services.telegram.send(
      ctx.config.TELEGRAM_ALLOWED_USER_ID,
      `📌 New reminder: ${payload.title}`,
    );
  });
},

Event handlers can also live in the handlers export, which the loader wires up automatically:

module.exports = {
  id: 'reminder-notifier',
  init(ctx) { /* … */ },
  handlers: {
    'reminders:created': async (payload, ctx) => {
      await ctx.services.telegram.send(/* … */);
    },
    'reminders:completed': async (payload, ctx) => {
      // …
    },
  },
};

Event names are conventionally <plugin-id>:<topic> so it’s clear which plugin owns each event.

6. Graceful-degradation pattern

A plugin that uses three integrations and one cross-plugin read but degrades to “do less” when any of them is unavailable:

init(ctx) {
  ctx.schedule.dailyAt('08:00', async () => {
    const lines = [];

    // Cross-plugin data — guard on length
    const forecast = ctx.data.read('weather', 'forecast');
    if (forecast.length) {
      lines.push(`☀️ ${forecast[0].summary}`);
    }

    // AI — guard on isReady (covers "not configured" + "user revoked")
    if (ctx.services.ai.isReady()) {
      const reflection = await ctx.services.ai.generate(
        `Write a single warm sentence about starting the day well.`,
      );
      lines.push(reflection);
    }

    // Calendar — guard on isReady (the user might not have set up Calendar)
    if (ctx.services.calendar.isReady()) {
      const events = await ctx.services.calendar.fetchEvents();
      const today = events.filter(e => isToday(e.start));
      if (today.length) {
        lines.push(`📅 ${today.length} event${today.length === 1 ? '' : 's'} today.`);
      }
    }

    if (!lines.length) return;

    // Delivery — final isReady guard
    if (ctx.services.telegram.isReady()) {
      await ctx.services.telegram.send(
        ctx.config.TELEGRAM_ALLOWED_USER_ID,
        lines.join('\n'),
      );
    }
  }, { timezone: 'America/Los_Angeles', tickId: 'morning-frame' });
},

function isToday(iso) {
  if (!iso) return false;
  const d = new Date(iso);
  const now = new Date();
  return d.getFullYear() === now.getFullYear()
    && d.getMonth() === now.getMonth()
    && d.getDate() === now.getDate();
}

The pattern: every external capability is wrapped in an isReady() check; every data source is checked for length. When the user reconfigures or grants something new, the next tick picks up the improvement automatically.

Where to next

  • Authoring Agents — the full author’s reference (manifest fields, ctx surface, runtime panels, sharing).
  • Using Agentum — the operator’s view (Permissions tab, Schedulers tab, dashboard).
  • Troubleshooting — when an agent stops behaving.