Send an Action
Turn a routed Decision into an HTTP request, then send it.
Sending is the one step the CLI does not do. A Decision names an Action by its id, and
@siftline/actions sends it:
npm install @siftline/actionsDeclare your Actions once with defineActions. Each has an id, an Action kind and a config
for that kind. Two Actions may share a kind: tickets can go to one webhook and escalations to
another. Every config is checked against its kind's schema when you define it, so a missing URL
fails at startup rather than on the first Record.
import { defineActions } from "@siftline/actions";
// Rules name an Action by its id. The kind says how it is sent; two Actions may share one.
// Every config is checked here, so a bad URL fails at startup.
export const actions = defineActions({
escalations: {
kind: "slack_incoming_webhook",
config: { url: "https://hooks.slack.com/services/T000/B000/XXXX" },
},
"linear-tickets": {
kind: "webhook",
config: { url: "https://example.com/hooks/siftline", secret: "shared-secret" },
},
});
dispatch(decision, actions, recipe) sends a routed Decision to the Action it selected. It
builds the request with that Action's Adapter, sends it once through the global fetch, and
returns { action, request, response }. Pass { fetch } to send through another one, such as
a test stub or a Worker's.
Building and sending are still two steps underneath. An Adapter's build is pure: the same
Decision, config and Recipe always produce the same bytes. So a preview is build alone, with
nothing sent:
import { dispatch, slackIncomingWebhook } from "@siftline/actions";
import type { Decision, Recipe } from "@siftline/core";
import { actions } from "./actions";
export async function send(decision: Decision, recipe: Recipe) {
// `null` when the Decision went to Review or its Rule selected no Action. Otherwise one attempt
// through the global `fetch`, no retries: `retryable` on the error says whether a second try is
// worth it.
return dispatch(decision, actions, recipe);
}
// Preview is `build` without `perform`: the exact request, and nothing sent.
export async function preview(decision: Decision, recipe: Recipe) {
return slackIncomingWebhook.build(decision, actions.escalations.config, recipe);
}
The webhook
The body is the Decision line itself, with no envelope, so the receiver runs parseDecision
on it. With a secret the request carries an HMAC-SHA256 of the body. Every request carries an
Idempotency-Key of <decision id>:<action id>, which is the key to deduplicate on if the
same Decision is ever sent twice:
{
"method": "POST",
"url": "https://example.com/hooks/siftline",
"headers": {
"Content-Type": "application/json",
"Idempotency-Key": "0192f3c2-7b1e-7c4a-9f0e-000000000002:linear-tickets",
"User-Agent": "siftline-actions/<version>",
"X-Siftline-Signature": "sha256=bbc070b8d15bf920a168bc45dc8b6b04a946cc430c77153de28e9502cd8f57c7"
},
"body": {
"format": 1,
"id": "0192f3c2-7b1e-7c4a-9f0e-000000000002",
"recordId": "msg-2",
"recipe": {
"name": "support-inbox",
"version": 2
},
"model": "jev-1.13.0",
"judgedAt": "2026-09-22T09:00:00.000Z",
"trimmed": false,
"answers": {
"category": "question",
"wants_human": false,
"urgency": 0
},
"questions": {
"category": {
"confidence": 1,
"probabilities": {
"complaint": 0,
"question": 1,
"other": 0
}
},
"wants_human": {
"probability": 0.02,
"confidence": 0.96
},
"urgency": {
"score": 0.2,
"confidence": 0.85,
"probabilities": {
"0": 0.85,
"1": 0.1,
"2": 0.05
}
}
},
"confidence": 0.85,
"review": false,
"rule": "ticket",
"action": "linear-tickets",
"usage": {
"inputTokens": 400,
"outputTokens": 60
}
},
"idempotencyKey": "0192f3c2-7b1e-7c4a-9f0e-000000000002:linear-tickets"
}
Slack
The Slack adapter posts one message to an incoming webhook. It needs the Recipe because a Score's level description lives there, not in the Decision:
{
"method": "POST",
"url": "https://hooks.slack.com/services/T000/B000/XXXX",
"headers": {
"Content-Type": "application/json",
"Idempotency-Key": "0192f3c2-7b1e-7c4a-9f0e-000000000001:escalations",
"User-Agent": "siftline-actions/<version>"
},
"body": {
"text": "*support-inbox* · msg-1\ncategory: complaint (97%)\nwants_human: yes (98%)\nurgency: 2 · Today (90%)\nrule escalate"
},
"idempotencyKey": "0192f3c2-7b1e-7c4a-9f0e-000000000001:escalations"
}
Slack has no idempotency of its own, so a redelivery past your own guard posts twice.
Failures
dispatch returns null and sends nothing when the Decision went to Review or its Rule
selected no Action. It throws ActionBuildError when the Decision names an Action id you did
not define, which is what a stale Rule looks like. defineActions throws the same error for an
unknown kind or an invalid config, naming the Action id.
A send that does not land throws ActionFailedError: a non-2xx status or a fetch that
rejects. It carries the status and a retryable flag that is true for 408, 429, 5xx and
network failures. dispatch retries nothing and sets no timeout: pass { signal } for that.
build alone throws ActionBuildError when the Decision selected no Action. It does not check
review; dispatch does, because it is the step that sends.