Skip to content
PricingReleases

Building a Trigger Plugin

A trigger plugin makes your plugin an event source for event-mode automations: an external system POSTs to Recursive, your plugin authenticates and normalizes the payload, and Recursive dispatches an agent session for every automation subscribed to that event. All provider specifics — signature schemes, handshakes, payload shapes — live in your plugin; core stays integration-agnostic.

Scaffold a working starting point with:

plugin_scaffold({ id: "my-provider", capabilities: ["trigger", "onboarding"] })
  1. The provider POSTs to the inbound ingress URL:

    POST /api/triggers/inbound/<pluginId>/<connectionId>

    <pluginId> is your plugin’s manifest id; <connectionId> selects a configured connection (usually default).

  2. Recursive resolves your triggers.module and calls verify(rawBody, headers, connection). Rejected requests get a 401; handshake results are echoed verbatim.

  3. Subscribers are checked before normalize() runs. If no enabled automation has an event trigger on this plugin + connection, the route answers 202 with dispatched: 0, reason: 'no subscribed automations' and your normalize() is never called.

  4. Verified payloads go to normalize(payload, headers, connection), which returns zero or more normalized events.

  5. Each event’s event_type is matched against enabled automations with an event trigger (mode: "event", same plugin_id / event_type / connection_id); the event’s match map is compared against each automation’s filters. Matches dispatch agent sessions with the whole normalized event injected as trigger context.

See Ingress reference for the exact order of operations and every status code the route can return.

triggers.module points at a JS/TS file exporting verify and normalize (named exports, a default-exported object, or both). The contract is typed as TriggerSourceModule, which lives in @recursive/core-types/automation and is re-exported by @recursive/plugin-sdk — both imports work:

// What the bundled plugins actually use (inside the monorepo):
import type { TriggerSourceModule, TriggerVerifyResult, NormalizedTriggerEvent } from '@recursive/core-types/automation';
// Third-party / external plugins — one package for the whole authoring contract:
import type { TriggerSourceModule, TriggerVerifyResult, NormalizedTriggerEvent } from '@recursive/plugin-sdk';

TriggerConnection and TriggerConnectionRecord are exported from both places too.

Authenticates an inbound request. Sync or async. Returns a TriggerVerifyResult:

ResultMeaning
{ ok: true }Authentic — proceed to normalize().
{ ok: false, reason? }Rejected — logged, request gets a 401.
{ handshake: true, body, status?, contentType? }Provider setup handshake — the route echoes body verbatim (with optional status / contentType) and stops. Use this for challenge/response flows like Slack’s url_verification.

The connection argument is a TriggerConnection:

FieldDescription
settingsThe plugin’s resolved settings blob (manifest defaults + values the user saved). This is the source of truth for credentials — read your own keys here (e.g. settings.signing_secret).
secretConvenience credential — the conventional signing secret derived from settings (signing_secret / secret / webhook_secret), or the builtin webhook’s per-automation secret.
configFree-form provider config (workspace id, channel, OAuth token, …).

Always verify signatures with a constant-time compare (crypto.timingSafeEqual), never ===.

Authenticate before answering handshakes. Providers sign their verification requests — check the signature first, and only then return a handshake result. Echoing a challenge to an unauthenticated caller lets anyone “confirm” your endpoint.

Headers arrive normalized: all header names are lowercased, and multi-value headers are joined with ', '. Read headers['stripe-signature'], not headers['Stripe-Signature'] — the capitalized form is always undefined.

Converts a verified payload into zero or more NormalizedTriggerEvents. Return [] or null for deliveries that should not dispatch anything — bot echoes, pings, event types you don’t handle.

What payload actually is: the route parses the raw request body for you — payload is JSON.parse(rawBody) (or {} for an empty body), but when parsing fails it is the raw string, passed through unparsed. It can therefore be a string, number, array, or null, not just an object — narrow defensively before reading properties:

export function normalize(payload, headers, connection) {
const body = payload && typeof payload === 'object' ? payload : {};
// ...
}
FieldPurpose
event_typeRequired. Matched against each subscribed automation’s event_type.
titleOne-liner used for the dispatched session’s prompt/title.
summaryShort summary of the event body.
urlPermalink back to the source (thread, issue, PR).
project_hintProject slug hint when the payload implies one.
delivery_idId used for retry de-duplication. Ingress dedups on <plugin>/<connection>:<delivery_id> with a 10-minute TTL — a second event carrying the same id within that window is silently dropped. Use the provider’s delivery id, but if one provider delivery normalizes into multiple events, give each sibling a distinct delivery_id (e.g. suffix the provider id with :alert, :comment) or the dedup cache swallows all but the first.
matchMap of fields compared against the automation’s filters (e.g. { channel: "C123" }).
rawThe raw provider payload, preserved for the dispatched agent.
{
"triggers": {
"module": "./triggers/index.js",
"setupSkill": "connecting-my-provider",
"requiredSettings": ["signing_secret"],
"events": [
{ "type": "issue.created", "label": "Issue created", "filters": ["project"] }
]
}
}
FieldRequiredDescription
moduleYesPath (relative to the plugin root) to the file exporting verify/normalize. Without it the whole block is dropped. Ship .js for third-party plugins; .ts works only under Bun (see the caution in The module contract).
eventsYes (lint)Non-empty array of { type, label, description?, filters? }. type must be a non-empty string; label is what the automation editor shows. A source with zero events does not appear in the trigger catalogplugin_validate warns about this.
setupSkillNoSkill id that walks the user through connecting the provider. Should match a skill the plugin ships (skills/<name>/SKILL.md) — plugin_validate warns when it doesn’t.
setupInstructionsNoMarkdown checklist of the provider-side setup steps (e.g. Slack: enable Event Subscriptions, paste the Request URL, subscribe to bot events, install to the workspace). Rendered as a platform-generated instructions onboarding step between the auto-generated copy-URL and await-event steps — you supply only this text and get the full trigger checklist for free. Must be a non-empty string, or the instructions step is skipped (plugin_validate warns).
requiredSettingsNoSettings keys that must be non-empty for the source to show as “Connected” in the automation editor. Defaults to every password-type setting.

Credentials live in the plugin’s settings block, not per-automation:

{
"settings": {
"defaults": { "signing_secret": "" },
"schema": [
{
"key": "signing_secret",
"type": "password",
"label": "Signing Secret",
"description": "Verifies inbound requests.",
"section": "Credentials"
}
]
}
}

password-type settings render masked in the plugin settings page, and your verify() reads them back through connection.settings. Pair this with an onboarding block (link → field → verify steps) so users can connect the provider without reading docs.

An automation subscribes to your events with an event trigger:

{
"mode": "event",
"plugin_id": "my-provider",
"event_type": "issue.created",
"connection_id": "default",
"filters": { "project": "web" }
}
  • plugin_id — your plugin’s id.
  • event_type — one of the types your normalize() emits (declared in triggers.events).
  • connection_id — which connection to use; default unless you support several.
  • filters — optional; every key must equal the corresponding key in the event’s match map.

In the automation editor, every enabled plugin with a triggers block appears in the “Add Trigger” picker automatically — no editor change needed. The clock scheduler ignores event triggers; the ingress route owns dispatch.

Ingress reference: order of operations and status codes

Section titled “Ingress reference: order of operations and status codes”

The ingress route processes every inbound POST in this exact order — knowing it saves debugging time:

  1. Rate limit — 60 requests / minute / connection. Over the limit → 429 (nothing else runs).
  2. Source lookup — unknown or disabled plugin id → 404.
  3. verify() — failed verification ({ ok: false }) → 401. A thrown exception in verify()400 (Verification error), not 401 — if you’re seeing 400s, your module is crashing, not rejecting.
  4. Handshake — a { handshake: true, body } result is echoed verbatim and processing stops.
  5. Subscriber check — no enabled automation subscribes to this plugin + connection → 202 with { ok: true, dispatched: 0, reason: 'no subscribed automations' }. normalize() never runs in this case: create the event automation first if you’re testing normalization.
  6. normalize() — a thrown exception → 400 (Normalization error).
  7. Dedup + match + dispatch — events whose delivery_id was already seen for this connection in the last 10 minutes are dropped; the rest are matched against subscribed automations and dispatched. Response: 202 with { ok: true, dispatched, deduped, events }.
StatusMeaning
202Accepted — check dispatched / deduped / reason in the body; dispatched: 0 is still a 202.
400Your verify() or normalize() threw an exception.
401verify() returned { ok: false } — bad or missing signature.
404Unknown plugin id, or the plugin is disabled / has no triggers.module.
429More than 60 requests in a minute on one connection.

Core ships a generic-webhook source so you can exercise the whole pipeline before writing a plugin. Create an automation with an event trigger on plugin_id: "generic-webhook", then:

Terminal window
BODY='{"event_type":"deploy.finished","title":"Deploy finished","url":"https://ci.example.com/run/42"}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')"
curl -X POST http://localhost:3333/api/triggers/inbound/generic-webhook/<connectionId> \
-H "Content-Type: application/json" \
-H "X-Recursive-Signature: $SIG" \
-d "$BODY"

If the connection has a secret, the request must carry X-Recursive-Signature: sha256=<hex hmac> over the raw body; with no secret the endpoint is open (trusted internal callers only). The payload’s event_type / event / type field (or an X-Recursive-Event header) becomes the normalized event_type.

The bundled slack-integration plugin is the reference implementation. Its shape:

  1. Manifesttriggers.module: "./triggers/index.ts" (a .ts module is fine here because Slack is a bundled plugin and the app runs under Bun — third-party plugins should ship .js), events app_mention and message (both filterable by channel / team), requiredSettings: ["signing_secret"], and setupSkill: "connecting-slack".
  2. verify() — checks Slack’s v0=HMAC_SHA256(signing_secret, "v0:timestamp:body") signature with a 5-minute replay window, and answers the url_verification handshake by returning { handshake: true, body: challenge } so Slack accepts the endpoint URL.
  3. normalize() — accepts only event_callback payloads for app_mention / message, skips bot echoes and message subtypes (loop prevention), then emits an event with the message text as title/summary, a deep-link url, Slack’s event_id as delivery_id, and match: { event_type, channel, team }.
  4. Automation — “when the app is mentioned in #releases, run an agent”: { mode: "event", plugin_id: "slack-integration", event_type: "app_mention", connection_id: "default", filters: { channel: "C0123456789" } }.

Point your Slack app’s Event Subscriptions request URL at https://<your-instance>/api/triggers/inbound/slack-integration/default, save the Signing Secret in the plugin’s settings, and mention the app.

If your trigger plugin ships inside the Recursive repo (under plugins/shared/ or plugins/<app>/), two extra pieces of wiring apply beyond the plugin folder itself:

  1. Add a registry entry. Bundled plugins must also be listed in plugins/shared/registry.json (the marketplace manifest for that scope) or they won’t appear in the Plugins view. Mirror the slack-integration entry:

    {
    "id": "my-provider",
    "name": "My Provider",
    "description": "Trigger Recursive automations from My Provider.",
    "version": "1.0.0",
    "color": "#4A154B",
    "icon": "comment",
    "provides": ["triggers", "settings", "skills"],
    "category": "integrations",
    "tags": ["my-provider", "integration", "automation"],
    "source": { "type": "bundled" },
    "min_recursive_version": "0.1.0",
    "dependencies": []
    }
  2. bundledOptIn in plugin.json. Bundled plugins load automatically at boot by default. Setting "bundledOptIn": true makes the plugin ship on disk and stay discoverable in the marketplace registry, but inactive until the user installs it into ~/.recursive/.../installed/ — the same opt-in model as adapter plugins. Use it for optional integrations (Slack, Memory, and Verify After Edit ship this way): the plugin’s tools, hooks, and trigger source don’t load until the user opts in.

Bundled plugins may point triggers.module at a .ts file — the app always runs under Bun, which imports TypeScript directly. Third-party plugins should ship .js (see The module contract).

  • The manifest icon field names a glyph from Recursive’s icon set (packages/core-ui/src/utils/icons.ts), not an image file. An unknown name silently renders nothing — pick an existing glyph (e.g. comment, github, bolt) or add one to that file.
  • An icon.png (or .jpg / .webp) beside plugin.json is optional. When present, the server auto-discovers it and the marketplace card shows it; when absent, the card falls back to the icon glyph, then to a letter badge. Don’t set thumbnail to a relative path like ./icon.png — auto-discovery only runs when thumbnail is unset, and the raw relative path won’t resolve in the browser.

plugin_validate({ id }) lints the whole surface: the triggers.module file must exist, events must be a non-empty array of { type, label }, requiredSettings keys must be in the settings schema, and setupSkill should match a shipped skill. Zero-event and dropped trigger blocks produce warnings so a source can’t silently vanish from the catalog.