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.envcarries 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 inwrangler.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 intoctx.secrets.<NAME>; a real env var wins over the UI-stored value.fetchHosts— the host allowlist forctx.fetch. A call to any host not listed is refused, andctx.fetchadds 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 (soacme-suiteownsacme_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.