Writing a skill
The defineSkill API end to end — the reversible-only contract, the SkillContext shape, a copyable handler, evals, and how to cause a side effect.
A skill is one unit of business logic you teach your deployment. The classic
skill is a reactive-mail skill: it reads one inbound email and writes
structured conclusions onto it — extra props, flags, a category, a named UI
component, and/or a proposed reply draft. The same defineSkill umbrella also
authors an agentic tool the assistant can call and a command button on a
thread; the trigger you give it decides how it runs.
This guide is the self-contained reference: the one design fact that governs every
integration, the function signature, the context you get, a handler you can copy,
and how to test it with no Cloudflare account. The built-in skills (otp,
shopify-order, …) are the worked templates — copy one and point it at your own
systems.
The one fact that governs everything: reversible-only
A reactive-mail skill can only do things a human can undo. Its verdict may set
extra/flags/category/component and propose adraft— and that's all. There is no send path, no mutation of Tinbox state, no routing surface. A drafted reply lands at statusdraftfor a human to review and send. This is a hard guarantee of the contract, enforced at module init — not a convention.
Internalize this before you architect anything, because it decides the whole shape of an integration:
- Your data lives in your store; Tinbox annotates. If your skill needs to persist something (a suggestion, a ticket, a CRM row), that record lives in your system. Tinbox stays a thin bridge that reads the mail and tags it — it is not the system of record for your side effect.
- The LLM-tool
runof an reactive skill MUST be read-only. A reactive-mail skill can never carry an imperativerun; an"acting"skill can never carry a reactivematch/toVerdict. The two are mutually exclusive and the engine throws if you mix them.
"But I want to cause a side effect on inbound mail"
This is the most common integration ("file it in my tracker", "add it to my list"), and it sits between the two pure cases. Use this decision table:
| You want to… | Use |
|---|---|
| tag / classify / extract from inbound mail | a reactive-mail skill (match → verdict) |
| have the assistant act on demand (chat / MCP / autopilot) | an agentic skill (agenticSkill(), kind: "imperative", requiresApproval for acting) |
| cause an idempotent, human-reversible external side effect on inbound mail | a reactive-mail skill whose match does an idempotent POST to your system (see below) |
The middle-ground pattern — a reactive skill that POSTs to your own endpoint —
is sanctioned when the side effect is idempotent and human-reversible: land a
status:new row a human triages, keyed on the email's Message-ID so a redelivery
is a no-op. That stays "reversible in spirit": nothing in Tinbox is mutated, and a
human can undo the row on your side. What you must not do is treat Tinbox as the
place the record lives, or perform an irreversible action (charge a card, send mail)
from a reactive skill — that's what an "acting" agentic skill (with approval) is
for.
// idempotent side effect: dedupe on Message-ID, POST to YOUR store
async match(ctx) {
if (!/recipe request/i.test(ctx.message.subject)) return null;
const messageId = ctx.headers("message-id") ?? ctx.message.id; // headers are lowercased
try {
await ctx.fetch("https://app.example.com/api/suggestions", {
method: "POST",
headers: { authorization: `Bearer ${ctx.env.MY_TOKEN}` },
body: JSON.stringify({ messageId, dish: extractDish(ctx.message.body) }),
});
} catch {
// your endpoint is down / slow — degrade, still tag the mail
}
return { extra: { recipe_request: true }, category: "primary", draft: thanksReply(ctx) };
}
The signature — defineSkill
Author every skill with defineSkill([...]). It validates at module init, so
a bad shape fails your build/deploy with a readable throw — never a silent runtime
surprise. (defineSkills is a still-supported alias.)
// tinbox.config.ts
import { defineSkill, builtinSkills } from "tinbox/cf/skills";
export const skillsConfig = defineSkill([
...builtinSkills, // OTP, password-reset, shipping, complaint, newsletter, …
mySkill, // your rules go here
]);
export default skillsConfig;
If you deploy via the published tinbox package, pass the same array on
TinboxConfig.skills. Import the API from tinbox/cf/skills (re-exported from
@tinbox/cf).
The context — SkillContext (ctx)
match(ctx) (and an imperative run(input, ctx)) receives a small, capability-
scoped context:
interface SkillContext {
message: CoreMessageFields; // the parsed email — the contract you read most
headers(name: string): string | null; // header lookup; names are LOWERCASED
env: Readonly<Record<string, string | undefined>>; // narrowed to your requiresEnv keys
fetch: SkillFetch; // sandboxed: timeout + host allowlist
extra: Readonly<Record<string, ExtraValue>>; // verdicts merged so far this pass
}
CoreMessageFields — what ctx.message gives you:
interface CoreMessageFields {
id: string;
threadId: string;
from: string;
to: string[];
subject: string;
body: string; // plain-text canonical
direction: "inbound" | "outbound";
status: string;
date: number; // header date — epoch millis
createdAt: number;
updatedAt: number;
extra?: ExtraFields;
}
Notes that save you a round-trip to the source:
- Headers are lowercased.
ctx.headers("message-id"),ctx.headers("list-unsubscribe")— always lowercase the name.Message-IDis the canonical dedupe key for any "ingest mail into my system" skill. ctx.envis narrowed. A skill only sees the keys it declares inrequiresEnv— the engine strips the rest. Set them once in Settings → Secrets (a Worker secret wins, else a sealed value); until every required secret is set the skill is gated off. When that happens the projection log says exactly which key is missing —[interpret-gate] skill 'X' skipped: required secret(s) unset — Y— so "why isn't my skill firing?" has a one-line answer (wrangler tailto see it).ctx.fetchis sandboxed. A hard timeout (default 5s), and a per-skill host allowlist: the skill declares the hosts it may reach withrequiresHosts(a skill that declares none can't fetch at all), optionally capped further by a deployment-wideINTERPRET_FETCH_HOSTS(the two intersect). It returns parsed JSON by default ({ raw: true }for theResponse) and throws on non-2xx, a timeout, or a blocked host — sotry/catchand degrade.
The three flavors
1 — algorithmic (pure). A regex and a verdict. Instant, free, deterministic — reach for this first.
{
id: "invoice",
label: "Invoices",
kind: "algorithmic",
appliesTo: "message",
description: "Tag invoices and extract the amount.",
match(ctx) {
const hay = `${ctx.message.subject}\n${ctx.message.body}`;
if (!/\binvoice\b/i.test(hay)) return null; // didn't fire → null
const amount = hay.match(/\$\s?([\d,]+(?:\.\d{2})?)/)?.[1];
return {
extra: { invoice: true, ...(amount ? { invoice_amount: amount } : {}) },
category: "primary",
};
},
}
2 — algorithmic that calls an API (async). Same flavor, async match, enriched
from an external system. Declare requiresHosts + requiresEnv, always
try/catch the fetch. (shopify-order is the canonical copy-me template.)
{
id: "order-status",
label: "Order status",
kind: "algorithmic",
appliesTo: "message",
requiresEnv: ["STORE", "STORE_TOKEN"],
requiresHosts: ["myshopify.com"],
async match(ctx) {
const id = ctx.message.subject.match(/#(\d+)/)?.[1];
if (!id) return null;
try {
const order = await ctx.fetch(`https://${ctx.env.STORE}/orders/${id}.json`, {
headers: { "X-Token": ctx.env.STORE_TOKEN ?? "" },
});
return { extra: { order_total: order.total }, component: "order-card", category: "primary" };
} catch (err) {
return { extra: { order_id: id, order_lookup_error: String(err) }, category: "primary" };
}
},
}
3 — LLM. Give instructions + a toVerdict; the engine runs the model on your
own key and maps the structured output to a verdict. The same read-only contract
applies — an LLM skill's tool run must never send or mutate.
Triggers beyond mail — agentic tools & command buttons
The same defineSkill authors the other triggers:
agentic— a tool the assistant calls in chat, over MCP, or in the autonomous loop. UseagenticSkill():kind: "imperative", aninputSchema, andrun(input, ctx). An acting tool (one that sends or mutates) is automatically marked needs-approval, so a human stays in the loop.command— a button on a thread/message. UsecommandSkill():run(ctx), no input.
import { defineSkill, defaultSkills } from "tinbox/cf/skills";
defineSkill([
...defaultSkills(), // the reactive-mail built-ins
orderLookupSkill, // an `agentic` tool the assistant can call
archiveThreadSkill, // a `command` button on a thread
]);
Agentic skills should declare named Connections instead of handling ambient credentials directly. An unconfigured requirement is a hard gate: the tool is absent from chat, MCP, CLI, and autopilot, and cannot be invoked by id.
import { defineConnection } from "tinbox/cf/connections";
import { agenticSkill } from "tinbox/cf/skills";
import { z } from "zod";
export const store = defineConnection({
id: "store",
name: "Store",
hosts: ["api.store.example"],
secrets: [{ name: "token", env: "STORE_TOKEN" }],
});
export const orderLookup = agenticSkill({
id: "order-lookup",
label: "Order lookup",
description: "Look up an order by id.",
requires: ["store"],
visibility: "both", // "ui" is never offered to a model; "model" never renders in UI
inputSchema: z.object({ id: z.string() }),
async run({ id }, ctx) {
const connection = ctx.connections.store!;
return (
await connection.fetch(`https://api.store.example/orders/${id}`)
).json();
},
});
This is distinct from an LLM reactive skill's tools field. Reactive tools are
read-only enrichment used only while classifying one inbound message; they never
join the assistant/MCP/CLI tool surface. agenticSkill() is the supported path
for a tenant capability that an agent calls directly.
Evals — TDD a skill with no Cloudflare account
You can validate a skill fully offline and deterministic — no network, no
deploy, no account. runEvals runs your cases against the real engine; stubFetch
maps a URL → JSON (and accepts a function form, so you can assert the body of a
POST your skill makes). This is the fastest loop for authoring a skill, and a
genuine differentiator: write the cases first, watch them go green, then deploy.
import { runEvals, stubFetch } from "tinbox/cf/skills";
await runEvals(mySkill, {
cases: [
{
name: "tags a recipe request and posts it once",
message: {
subject: "Recipe request: carbonara",
body: "…",
headers: { "message-id": "<a@x>" },
},
fetch: stubFetch({
"https://app.example.com/api/suggestions": (req) => {
// assert the POST body, return the stubbed response
return { status: 201, json: { ok: true } };
},
}),
expect: (verdict) => verdict.extra?.recipe_request === true,
},
{
name: "ignores unrelated mail",
message: { subject: "hello", body: "" },
expect: (v) => v === null,
},
],
});
Enabling, disabling, gating
Built-ins are on by default; disable any per deployment from Settings → Skills,
which lists each skill's triggers and unmet requirements. An opt-in skill activates
only once its required secrets and named Connections are configured. If a skill
isn't firing, check requiresEnv and requires in Settings first.
Where to go next
- The built-in skills are the worked templates — browse them in the skills reference and copy the closest one.
- Connecting MCP — how an agentic skill becomes a tool the assistant (and external MCP clients) can call.