Skip to content

Extend: Plugins — routes, crons, mail hooks, and private tables

Lane 4 of Extend Tinbox — in-worker modules with definePlugin: HTTP routes, cron handlers, inbound/outbound mail hooks, namespaced D1 tables, and scoped secrets.

A plugin is the escape hatch for logic that has to live at the worker edge — the HTTP routes, cron schedules, and mail hooks that can't be expressed as a reactive parser or a view. It's PACKAGING plus those edge hooks, bundled into your tenant worker at deploy time. You compose one into config.plugins in tinbox.config.ts, and createWorker() wires its routes, crons, migrations, and mail hooks into the same worker that already runs your inbox.

A complete plugin

import { definePlugin } from "tinbox/cf/plugin";

export default function acmeSuite() {
  return definePlugin({
    name: "acme-suite",                    // lowercase-dash; namespaces routes + tables
    version: "1.0.0",
    capabilities: {
      secrets: [{ name: "AIRTABLE_TOKEN", required: true, description: "Airtable API key" }],
      fetchHosts: ["api.airtable.com"],    // ctx.fetch host allowlist
      tables: ["acme_suite_events"],       // must be namespaced with name_ prefix
    },

    // Mount HTTP routes under /plugins/acme-suite/*
    routes(router, ctx) {
      router.post("/webhook", async (c) => {
        // c.env has bindings/secrets at request time
        return c.json({ ok: true });
      });
    },

    // Cron handlers keyed by cron expression (add the matching trigger to wrangler.jsonc)
    scheduled: {
      "0 2 * * *": async (ctx) => { ctx.log("nightly run"); },
    },

    // React to inbound mail AFTER it's stored (read-only; side-effects only — to CLASSIFY, use a skill)
    async onEmail(email, ctx) {
      // email.from, email.to, email.subject, email.text, email.headers(name)
      // ctx.fetch (host-allowlisted), ctx.secrets.AIRTABLE_TOKEN, ctx.llm, ctx.db, ctx.log
    },

    // Fire AFTER a successful send — logging/telemetry (the classic Airtable-summary case)
    async onSent(sent, ctx) {
      if (sent.originator?.startsWith("agent:")) {
        const line = await ctx.llm.summarize(sent.text, { instructions: "one sentence" });
        // await ctx.fetch("https://api.airtable.com/…", { … })
      }
    },
  });
}

Then in tinbox.config.ts:

import { defineConfig } from "tinbox/cf";
import acmeSuite from "./plugins/acme-suite";

export default defineConfig({
  // …
  plugins: [acmeSuite()],
});

The hooks

Every hook below ships today. A plugin can supply any subset — a webhook-only plugin is just routes, a telemetry plugin is just onSent.

  • routes(router, ctx) — a Hono sub-app mounted under /plugins/<name>/*. Handlers get the request context; c.env carries bindings and resolved secrets. Use it for inbound webhooks, small JSON endpoints, or an OAuth callback.
  • scheduled — an object keyed by cron expression. Each handler runs when its cron fires. The expression must also exist as a trigger in wrangler.jsonc — the worker only wakes on crons Cloudflare knows about, so the key and the trigger have to match.
  • onEmail(email, ctx) — runs AFTER an inbound message is stored. Read-only over the mail plus side-effects (post to an external system, enqueue work). Use it to act on mail, not to label it — for tags, flags, or a category, use a reactive skill (see the boundary below).
  • onSend(send, ctx) — runs BEFORE a message is dispatched, and can mutate or suppress it. Return { suppress: true, reason } to drop the send. The first suppress wins, and hooks fail OPEN — an exception in your hook is swallowed so a bug can never black-hole a tenant's mail.
  • onSent(sent, ctx) — runs AFTER a successful send. Telemetry only; the mail is already gone. This is the classic "summarize the outbound and log it to Airtable" case.
  • migrations + ctx.db — the plugin's private D1 tables and the namespaced handle that reads and writes them (below).

Capabilities

capabilities declares what the plugin is allowed to reach. Nothing outside the declaration is available at runtime.

  • secrets — named secrets (required + description). Each is fillable two ways: a real worker env var, or Settings → Plugins in the inbox UI. Both resolve into ctx.secrets.<NAME>; a real env var wins over the UI-stored value.
  • fetchHosts — the host allowlist for ctx.fetch. A call to any host not listed is refused, and ctx.fetch adds a timeout — so a plugin can't quietly phone home or hang the worker.
  • tables — the D1 tables this plugin owns. Each must be prefixed <name>_, with the plugin name's dashes turned into underscores (so acme-suite owns acme_suite_*).

Private tables

ctx.db is a namespaced D1 handle. Any statement that touches a table the plugin didn't declare in capabilities.tables throws before it runs — so a plugin can't read your mail store or another plugin's data by accident.

You create the declared tables with migrations: an array of { id, sql } entries applied once, in order, and tracked in a ledger so a redeploy doesn't re-run them.

migrations: [
  {
    id: "0001-events",
    sql: `CREATE TABLE IF NOT EXISTS acme_suite_events (
      id TEXT PRIMARY KEY,
      kind TEXT NOT NULL,
      created_at INTEGER NOT NULL
    );`,
  },
],

The LLM

ctx.llm is the tenant's own configured model — one-shot complete / summarize, on the tenant's keys. Check ctx.llm.available before you call it, since a tenant may not have a model configured:

if (ctx.llm.available) {
  const line = await ctx.llm.summarize(sent.text, { instructions: "one sentence" });
}

Bundling skills and tools

A plugin can also carry skills (reactive parsers) and agentTools, so a single package ships its edge hooks alongside the classification and agent surface that go with them. See Parsers (/docs/guides/extend-parsers) for reactive skills and Views (/docs/guides/extend-views) for agent tools and UI tiles.

Ordering

Within one inbound message, reactive skills run before onEmail — so by the time your hook fires, the message already carries whatever tags or category a skill assigned, and the hook can branch on them. On the outbound side, onSend runs before dispatch (it can still stop the send) and onSent runs after (the send has already happened).

Boundary: act, don't annotate

onEmail is for acting on mail — pushing it somewhere, kicking off external work. To annotate inbound mail (add a tag, set a flag, assign a category), use a reactive skill (Parsers, lane 1) instead — that's the surface built for labeling, and it runs first so hooks see the result. And if all you need is to POST a raw event on delivery or send, reach for a built-in webhook — no code, no plugin.

Which lane instead?

  • To classify inbound mail (tags, flags, category) → Parsers, lane 1 (/docs/guides/extend-parsers).
  • To render a UI tile or add an agent tool → Views, lane 5 (/docs/guides/extend-views).
  • To send from another app → Scripts, lane 3 (/docs/guides/extend-scripts).

Back to the hub: /docs/guides/extend.