Siftline
Guide

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/actions

Declare 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.

actions.ts
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:

act.ts
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:

the built webhook request
{
  "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:

the built Slack request
{
  "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.

Next: everything above from TypeScript.

On this page