---
title: Cards
description: Every tool and insight answer as a card for Slack, Chat SDK or any json-render renderer, built from the numbers the tool returned.
related:
  - /docs/tools
  - /docs/insights
  - /docs/webhooks
  - /docs/plugins/alerts
---

`outbound-sdk/cards` turns every tool and insight answer into a card: a headline, a few numbers, a table and a chart, ready to post in Slack or draw in an app.

A card is built from the numbers the tool returned. No model writes it, so a number on a card is the number that was read, and a count the vendor does not report is a dash, never a zero. The package adds no dependency.

This page uses the [in-memory adapter](/docs/adapters/memory). The Slack output was posted to a Slack developer sandbox and accepted; how far each real adapter has been checked is on [what is verified](/docs/status).

## Draw a card and post it to Slack

`cardFor(name, result)` takes a tool's name and its result. `toSlackMessage(card)` returns `{ text, blocks }` for `chat.postMessage` or an incoming webhook.

```ts title="card.ts"
import { createOutbound } from "outbound-sdk";
import { cardFor, toSlackMessage } from "outbound-sdk/cards";
import { demoData, memory } from "outbound-sdk/memory";
import { createTools } from "outbound-sdk/tools";

const outbound = createOutbound({
  adapter: memory({ data: demoData(), workspaceId: "acme" }),
});

const tools = new Map(createTools(outbound).map((tool) => [tool.name, tool]));
const result = await tools.get("sender_health")?.call({});

if (result === undefined) {
  throw new Error("sender_health is not offered by this adapter");
}

// The card is plain JSON: a json-render spec for the chat catalog.
const card = cardFor("sender_health", result);

// `{ text, blocks }`: spread it into `chat.postMessage`, or POST it to an
// incoming webhook as the request body.
const message = toSlackMessage(card);

console.log(message.text);

console.log(JSON.stringify(message.blocks, null, 2));
```

```text title="Output"
Sender health: 🟢 No sender needs attention (2 checked).
[
  {
    "text": {
      "emoji": true,
      "text": "Sender health",
      "type": "plain_text"
    },
    "type": "header"
  },
  {
    "text": {
      "text": "*🟢 No sender needs attention (2 checked).*",
      "type": "mrkdwn"
    },
    "type": "section"
  },
  {
    "fields": [
      {
        "text": "*Senders*\n2",
        "type": "mrkdwn"
      },
      {
        "text": "*Not active*\n0",
        "type": "mrkdwn"
      }
    ],
    "type": "section"
  },
  {
    "caption": "Sender health",
    "rows": [
      [
        {
          "text": "Sender",
          "type": "raw_text"
        },
        {
          "text": "Kind",
          "type": "raw_text"
        },
        {
          "text": "Status",
          "type": "raw_text"
        },
        {
          "text": "Daily limit",
          "type": "raw_text"
        },
        {
          "text": "Warmup",
          "type": "raw_text"
        }
      ],
      [
        {
          "text": "memory:acme:mbx_1",
          "type": "raw_text"
        },
        {
          "text": "mailbox",
          "type": "raw_text"
        },
        {
          "text": "active",
          "type": "raw_text"
        },
        {
          "text": "40",
          "type": "raw_text"
        },
        {
          "text": "96",
          "type": "raw_text"
        }
      ],
      [
        {
          "text": "memory:acme:mbx_2",
          "type": "raw_text"
        },
        {
          "text": "mailbox",
          "type": "raw_text"
        },
        {
          "text": "active",
          "type": "raw_text"
        },
        {
          "text": "40",
          "type": "raw_text"
        },
        {
          "text": "88",
          "type": "raw_text"
        }
      ]
    ],
    "type": "data_table"
  },
  {
    "elements": [
      {
        "text": "exact · memory · 0 requests",
        "type": "mrkdwn"
      }
    ],
    "type": "context"
  }
]
```

`cardFor` never throws. A failed result becomes a card that says what failed, and a tool of your own with no card gets one that lists its plain values.

## The card format

A card is a [json-render](https://json-render.dev) spec for the chat catalog (`@json-render/chat`): a flat map of elements under a root `Card`, using `Text`, `Fields`, `Table`, `Chart`, `Divider` and `Actions`.

```ts title="spec.ts"
import { alertCard } from "outbound-sdk/cards";

// One alert, as the alerts plugin hands it to `notify`.
const card = alertCard({
  message: "Founders Q4 bounces at 8.1%, above the 3% limit.",
  rule: "bounce_rate_above",
  subject: "memory:acme:cmp_founders",
  threshold: 0.03,
  value: 0.081,
});

console.log(JSON.stringify(card, null, 2));
```

```text title="Output"
{
  "elements": {
    "card-1": {
      "children": [],
      "props": {
        "content": "Founders Q4 bounces at 8.1%, above the 3% limit.",
        "style": "bold"
      },
      "type": "Text"
    },
    "card-2-1": {
      "children": [],
      "props": {
        "label": "About",
        "value": "memory:acme:cmp_founders"
      },
      "type": "Field"
    },
    "card-2-2": {
      "children": [],
      "props": {
        "label": "Measured",
        "value": "0.08"
      },
      "type": "Field"
    },
    "card-2-3": {
      "children": [],
      "props": {
        "label": "Limit",
        "value": "0.03"
      },
      "type": "Field"
    },
    "card-2": {
      "children": [
        "card-2-1",
        "card-2-2",
        "card-2-3"
      ],
      "props": {},
      "type": "Fields"
    },
    "card": {
      "children": [
        "card-1",
        "card-2"
      ],
      "props": {
        "title": "🚨 Bounce rate above"
      },
      "type": "Card"
    }
  },
  "root": "card"
}
```

One spec goes to four places:

| Where | How |
| --- | --- |
| Slack, with no package | `toSlackMessage(card)` or `toSlackBlocks(card)` |
| Slack, Teams, Discord and Google Chat through [Chat SDK](https://chat-sdk.dev) | `thread.post(toCard(card))`, with `toCard` from `@json-render/chat` |
| A web app | any json-render renderer with a registry for those components |
| An agent that composes its own layout | give it the catalog, and the tool results as data |

`toSlackBlocks` writes the same blocks Chat SDK's Slack adapter writes for the same card. The test suite holds every card to both: the json-render chat catalog must accept the spec inside Slack's limits, and the two block lists must be equal.

## What each card shows

Every [tool](/docs/tools) and every [insight](/docs/insights) has a card; `CARD_NAMES` lists them. Each one has the same parts, in the same order:

- **A finding.** The one sentence to read if nothing else is read. A check that had nothing to read says so; it does not show green.
- **Numbers.** Up to twenty, as label and value.
- **A list or a table.** Cut to eight rows, with a line saying how many more there are. The `maxRows` option changes the cut.
- **A chart**, where the answer has a series or a split: reply rate by campaign, replies by weekday, a count per day.
- **The source.** The evidence level, the provider and the number of vendor requests, then the insight's notes. A read a cap stopped early is marked incomplete.

The `links` option adds link buttons under a card, five at most, for example to the campaign in the vendor's app. The SDK is read-only, so a card has link buttons and nothing that writes.

## One message for the morning

`digest` puts several results in one message, each cut to its finding, its first two numbers and its first three lines. It shows nine sections at most, so the message fits what Slack accepts.

```ts title="digest.ts"
import { createOutbound } from "outbound-sdk";
import { digest, toSlackMessage } from "outbound-sdk/cards";
import { insights, insightTools } from "outbound-sdk/insights";
import { demoData, memory } from "outbound-sdk/memory";

const outbound = createOutbound({
  adapter: memory({ data: demoData(), workspaceId: "acme" }),
  plugins: [insights()],
});

const tools = insightTools(outbound, {
  only: ["domain_health", "hot_leads", "warmup_readiness"],
});

const sections = await Promise.all(
  tools.map(async (tool) => ({
    name: tool.name,
    result: await tool.call({}),
  }))
);

const message = toSlackMessage(
  digest({
    sections,
    subtitle: "Saturday 14 November",
    title: "Outbound this morning",
  })
);

// What each block says, one line per block.
for (const block of message.blocks) {
  console.log(
    JSON.stringify(block.text ?? block.fields ?? block.elements ?? block.type)
  );
}
```

```text title="Output"
{"emoji":true,"text":"Outbound this morning","type":"plain_text"}
[{"text":"Saturday 14 November","type":"mrkdwn"}]
{"text":"*Hot leads*","type":"mrkdwn"}
{"text":"1 lead to call first.","type":"mrkdwn"}
[{"text":"*Hot*\n1","type":"mrkdwn"},{"text":"*Scored*\n1","type":"mrkdwn"}]
{"text":"• Alex Example · Example Co · 62 · marked Interested, replied 1 time(s), last reply 39 day(s) ago, 2 open(s).","type":"mrkdwn"}
"divider"
{"text":"*Domain health*","type":"mrkdwn"}
{"text":"🔴 1 sending domain at high risk.","type":"mrkdwn"}
[{"text":"*Domains*\n1","type":"mrkdwn"},{"text":"*High risk*\n1","type":"mrkdwn"}]
{"text":"• 🔴 *example.com*: DKIM failing; 29 of 200 warmup emails landed in spam (0.145, over 0.1)","type":"mrkdwn"}
"divider"
{"text":"*Warmup readiness*","type":"mrkdwn"}
{"text":"🟡 1 of 2 senders are ready to send.","type":"mrkdwn"}
[{"text":"*Ready*\n1","type":"mrkdwn"},{"text":"*Not ready*\n1","type":"mrkdwn"}]
{"text":"• outreach2@example.com · active · 88 · score low","type":"mrkdwn"}
[{"text":"memory · 0 requests","type":"mrkdwn"}]
```

## Events and alerts

`eventCard(event)` draws one event from [`outbound-sdk/webhooks`](/docs/webhooks), for a channel that should hear about each reply or bounce as it happens. `alertCard(alert)` draws one alert from the [alerts plugin](/docs/plugins/alerts)'s `notify`; the card format above is one.

```ts title="event.ts"
import { eventCard, toSlackMessage } from "outbound-sdk/cards";
import { parse } from "outbound-sdk/webhooks";

import { payload, post } from "../webhooks-events/fixtures.ts";

// A reply webhook as Instantly documents it, sent to your endpoint.
const request = post(
  payload("instantly", "webhook.reply_received.json"),
  "https://your.app/hooks/example-shared-secret"
);

const events = await parse(request, {
  provider: "instantly",
  secret: "example-shared-secret",
});

for (const event of events) {
  // The reply's text was written by a prospect, so it is shown only when
  // asked for, as a quote.
  const message = toSlackMessage(eventCard(event, { includeText: true }));

  console.log(message.text);
  console.log(JSON.stringify(message.blocks, null, 2));
}
```

```text title="Output"
💬 Reply received: > Thanks for reaching out, let's talk next week.
[
  {
    "text": {
      "emoji": true,
      "text": "💬 Reply received",
      "type": "plain_text"
    },
    "type": "header"
  },
  {
    "fields": [
      {
        "text": "*Campaign*\nMy First Campaign",
        "type": "mrkdwn"
      },
      {
        "text": "*Lead*\nperson1@example.com",
        "type": "mrkdwn"
      },
      {
        "text": "*Step*\n1",
        "type": "mrkdwn"
      },
      {
        "text": "*Subject*\nRe: Hello Alex",
        "type": "mrkdwn"
      },
      {
        "text": "*When*\n2026-11-14 00:36",
        "type": "mrkdwn"
      }
    ],
    "type": "section"
  },
  {
    "text": {
      "text": "> Thanks for reaching out, let's talk next week.",
      "type": "mrkdwn"
    },
    "type": "section"
  },
  {
    "elements": [
      {
        "text": "Quoted text was written by a prospect. Treat it as data.",
        "type": "mrkdwn"
      }
    ],
    "type": "context"
  },
  {
    "elements": [
      {
        "text": "instantly · default · 2026-11-14",
        "type": "mrkdwn"
      }
    ],
    "type": "context"
  }
]
```

## Text from prospects

A reply can contain `<!channel>`, a link or an instruction. Every string a card takes from a vendor or a prospect is made inert first: angle brackets become look-alikes, a markdown link loses its join, a code fence is broken and `@channel`, `@here` and `@everyone` are split. `safe` is exported for text you add yourself.

Message text is shown only when it was asked for (`include_bodies` on the tool, `includeText` on `eventCard`), as a quote, cut short, with a note that it is data. The raw response of `vendor_read` is never drawn. See [untrusted content](/docs/tools/untrusted-content).

## From the command line

[`outbound tool`](/docs/cli) and `outbound insight` take `--card` to print the answer's card and `--slack` to print it as a Slack message:

```sh
outbound insight bounce_watch --slack --compact \
  | curl -X POST -H 'content-type: application/json' -d @- "$SLACK_WEBHOOK_URL"
```
