Skip to content

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 a draft — and that's all. There is no send path, no mutation of Tinbox state, no routing surface. A drafted reply lands at status draft for 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 run of an reactive skill MUST be read-only. A reactive-mail skill can never carry an imperative run; an "acting" skill can never carry a reactive match/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 maila 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 maila 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-ID is the canonical dedupe key for any "ingest mail into my system" skill.
  • ctx.env is narrowed. A skill only sees the keys it declares in requiresEnv — 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 tail to see it).
  • ctx.fetch is sandboxed. A hard timeout (default 5s), and a per-skill host allowlist: the skill declares the hosts it may reach with requiresHosts (a skill that declares none can't fetch at all), optionally capped further by a deployment-wide INTERPRET_FETCH_HOSTS (the two intersect). It returns parsed JSON by default ({ raw: true } for the Response) and throws on non-2xx, a timeout, or a blocked host — so try/catch and 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. Use agenticSkill(): kind: "imperative", an inputSchema, and run(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. Use commandSkill(): 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.