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"] })How events flow
Section titled “How events flow”-
The provider POSTs to the inbound ingress URL:
POST /api/triggers/inbound/<pluginId>/<connectionId><pluginId>is your plugin’s manifestid;<connectionId>selects a configured connection (usuallydefault). -
Recursive resolves your
triggers.moduleand callsverify(rawBody, headers, connection). Rejected requests get a 401; handshake results are echoed verbatim. -
Subscribers are checked before
normalize()runs. If no enabled automation has an event trigger on this plugin + connection, the route answers 202 withdispatched: 0, reason: 'no subscribed automations'and yournormalize()is never called. -
Verified payloads go to
normalize(payload, headers, connection), which returns zero or more normalized events. -
Each event’s
event_typeis matched against enabled automations with an event trigger (mode: "event", sameplugin_id/event_type/connection_id); the event’smatchmap is compared against each automation’sfilters. 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.
The module contract
Section titled “The module contract”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.
verify(rawBody, headers, connection)
Section titled “verify(rawBody, headers, connection)”Authenticates an inbound request. Sync or async. Returns a TriggerVerifyResult:
| Result | Meaning |
|---|---|
{ 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:
| Field | Description |
|---|---|
settings | The 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). |
secret | Convenience credential — the conventional signing secret derived from settings (signing_secret / secret / webhook_secret), or the builtin webhook’s per-automation secret. |
config | Free-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.
normalize(payload, headers, connection)
Section titled “normalize(payload, headers, connection)”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 : {}; // ...}| Field | Purpose |
|---|---|
event_type | Required. Matched against each subscribed automation’s event_type. |
title | One-liner used for the dispatched session’s prompt/title. |
summary | Short summary of the event body. |
url | Permalink back to the source (thread, issue, PR). |
project_hint | Project slug hint when the payload implies one. |
delivery_id | Id 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. |
match | Map of fields compared against the automation’s filters (e.g. { channel: "C123" }). |
raw | The raw provider payload, preserved for the dispatched agent. |
The manifest triggers block
Section titled “The manifest triggers block”{ "triggers": { "module": "./triggers/index.js", "setupSkill": "connecting-my-provider", "requiredSettings": ["signing_secret"], "events": [ { "type": "issue.created", "label": "Issue created", "filters": ["project"] } ] }}| Field | Required | Description |
|---|---|---|
module | Yes | Path (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). |
events | Yes (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 catalog — plugin_validate warns about this. |
setupSkill | No | Skill 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. |
setupInstructions | No | Markdown 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). |
requiredSettings | No | Settings keys that must be non-empty for the source to show as “Connected” in the automation editor. Defaults to every password-type setting. |
Settings and secrets
Section titled “Settings and secrets”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.
Wiring an event automation
Section titled “Wiring an event automation”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 yournormalize()emits (declared intriggers.events).connection_id— which connection to use;defaultunless you support several.filters— optional; every key must equal the corresponding key in the event’smatchmap.
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:
- Rate limit — 60 requests / minute / connection. Over the limit → 429 (nothing else runs).
- Source lookup — unknown or disabled plugin id → 404.
verify()— failed verification ({ ok: false }) → 401. A thrown exception inverify()→ 400 (Verification error), not 401 — if you’re seeing 400s, your module is crashing, not rejecting.- Handshake — a
{ handshake: true, body }result is echoed verbatim and processing stops. - 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. normalize()— a thrown exception → 400 (Normalization error).- Dedup + match + dispatch — events whose
delivery_idwas 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 }.
| Status | Meaning |
|---|---|
202 | Accepted — check dispatched / deduped / reason in the body; dispatched: 0 is still a 202. |
400 | Your verify() or normalize() threw an exception. |
401 | verify() returned { ok: false } — bad or missing signature. |
404 | Unknown plugin id, or the plugin is disabled / has no triggers.module. |
429 | More than 60 requests in a minute on one connection. |
Testing with the builtin generic webhook
Section titled “Testing with the builtin generic webhook”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:
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.
Worked example: Slack
Section titled “Worked example: Slack”The bundled slack-integration plugin is the reference implementation. Its shape:
- Manifest —
triggers.module: "./triggers/index.ts"(a.tsmodule is fine here because Slack is a bundled plugin and the app runs under Bun — third-party plugins should ship.js), eventsapp_mentionandmessage(both filterable bychannel/team),requiredSettings: ["signing_secret"], andsetupSkill: "connecting-slack". verify()— checks Slack’sv0=HMAC_SHA256(signing_secret, "v0:timestamp:body")signature with a 5-minute replay window, and answers theurl_verificationhandshake by returning{ handshake: true, body: challenge }so Slack accepts the endpoint URL.normalize()— accepts onlyevent_callbackpayloads forapp_mention/message, skips bot echoes and message subtypes (loop prevention), then emits an event with the message text astitle/summary, a deep-linkurl, Slack’sevent_idasdelivery_id, andmatch: { event_type, channel, team }.- 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.
Shipping a bundled plugin
Section titled “Shipping a bundled plugin”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:
-
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 theslack-integrationentry:{"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": []} -
bundledOptIninplugin.json. Bundled plugins load automatically at boot by default. Setting"bundledOptIn": truemakes 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
iconfield 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) besideplugin.jsonis optional. When present, the server auto-discovers it and the marketplace card shows it; when absent, the card falls back to theiconglyph, then to a letter badge. Don’t setthumbnailto a relative path like./icon.png— auto-discovery only runs whenthumbnailis unset, and the raw relative path won’t resolve in the browser.
Validating
Section titled “Validating”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.