Skip to content
Preview: outbound-sdk is not on npm yet and its repository is private. Instantly and HeyReach are beta, Smartlead is unverified.What is verified
outbound-sdk
Esc
↑↓navigate↵open⌘Jpreview
On this page

Cards

Every tool and insight answer as a card for Slack, Chat SDK or any json-render renderer, built from the numbers the tool returned.

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

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.

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));
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 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.

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));
{
  "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 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 and every insight 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.

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)
  );
}
{"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, for a channel that should hear about each reply or bounce as it happens. alertCard(alert) draws one alert from the alerts plugin’s notify; the card format above is one.

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));
}
💬 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.

From the command line

outbound tool and outbound insight take --card to print the answer’s card and --slack to print it as a Slack message:

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

Was this page helpful?