# Send an Action (/docs/guide/send-an-action)



Sending is the one step the CLI does not do. A Decision names an Action by its id, and
`@siftline/actions` sends it:

```bash
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.

```ts title="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:

```ts title="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-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:

```json title="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 [#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:

```json title="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 [#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](/docs/guide/from-code).
