Skip to content

Triage form submissions with Chat SDK

Build a Slack bot that triages form submissions with interactive cards. Forward, edit, or mark as spam without leaving Slack. Built with Chat SDK, Hono, and Resend.

7 min read
Last updated April 2, 2026

Build a Slack bot that triages form submissions where your team already works. When someone submits a form on your website, the bot posts an interactive card to a Slack channel. A reviewer clicks a button to forward the submission via email, edit it before forwarding, or mark it as spam. The whole workflow happens inside Slack, with no separate dashboard or inbox to monitor.

The bot is built with Chat SDK, a TypeScript library for building chatbots that work across Slack, Microsoft Teams, Discord, and other platforms from a single codebase. You write your logic once, and Chat SDK handles the platform-specific details, such as Block Kit on Slack or Adaptive Cards on Teams.

Deploy the template now, or read on for a deeper look at how it at all works.

The bot has three moving parts, including an HTTP endpoint that receives form data, a Redis store that temporarily holds submissions, and a Chat SDK bot that manages Slack interactions.

  1. An external service (your website, a form provider, a webhook) sends a POST request to /api/form with JSON data
  2. The bot generates a unique ID, stores the submission in Redis with a 7-day TTL, and posts an interactive card to a Slack channel
  3. A reviewer sees the card and takes one of three actions:
    • Forward Submission sends a styled HTML email via Resend to a configured recipient, then updates the card to show who forwarded it
    • Edit & Forward opens a modal where the reviewer can modify fields before forwarding
    • Mark as Spam updates the card and deletes the submission from Redis

Once an action is taken, the card updates in place. The reviewer sees the result immediately in the same channel, without a confirmation page or context switch.

The entire bot is about 200 lines across six files.

The HTTP layer uses Hono, a lightweight web framework. The form endpoint accepts any JSON body, assigns it a UUID, and hands it off to the bot:

src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { bot, postFormCard } from "./bot.js";
const app = new Hono();
app.use("/api/form", cors());
app.post("/api/form", (c) =>
c.req.json().then(async (formData: Record<string, unknown>) => {
const submissionId = crypto.randomUUID();
await Promise.resolve(postFormCard(formData, submissionId));
return c.json({ status: "received", submissionId });
})
);
app.post("/api/webhooks/:platform", (c) => {
const platform = c.req.param("platform");
if (platform !== "slack") {
return c.json({ error: `Unknown platform: ${platform}` }, 404);
}
return bot.webhooks.slack(c.req.raw);
});
export default app;

You can submit directly from a browser-based form on any origin. The webhook route at /api/webhooks/:platform handles Slack's interaction payloads (button clicks and modal submissions). The :platform parameter is already set up for adding other platforms later.

The bot itself is a Chat SDK instance with a Slack adapter and Redis-backed state:

src/bot.ts
import { Chat } from "chat";
import { createSlackAdapter } from "@chat-adapter/slack";
import { createRedisState } from "@chat-adapter/state-redis";
export const bot = new Chat({
adapters: {
slack: createSlackAdapter(),
},
state: createRedisState(),
userName: "form-bot",
});

That's the entire bot setup. Chat SDK handles Slack's signature verification, payload parsing, and response formatting. The state object is a Redis adapter that Chat SDK uses for its internal state management, and the same Redis connection is reused to store form submissions.

Cards are built using Chat SDK's component functions. These are platform-agnostic: on Slack, they render as Block Kit, on Teams, they'd render as Adaptive Cards. Here's the card that appears when a new submission arrives:

src/lib/cards.ts
import { Actions, Button, Card, Fields, Field, Divider } from "chat";
export const newSubmissionCard = (
formData: Record<string, unknown>,
submissionId: string
) =>
Card({
children: [
Fields(
Object.entries(formData).map(([key, value]) =>
Field({ label: key, value: String(value) })
)
),
Divider(),
Actions([
Button({
id: "forward",
label: "Forward Submission",
style: "primary",
value: submissionId,
}),
Button({
id: "edit",
label: "Edit & Forward",
style: "primary",
value: submissionId,
}),
]),
Actions([
Button({
id: "spam",
label: "Mark as Spam",
style: "danger",
value: submissionId,
}),
]),
],
title: "New Form Submission",
});

The form data is dynamic. Whatever keys and values are in the JSON body become fields on the card. A submission with {"Name": "Jane", "Email": "jane@example.com"} produces a card with two fields. A submission with ten fields produces a card with ten fields. No schema changes needed.

When a reviewer clicks a button, Chat SDK routes the event to the right handler based on the action ID:

src/bot.ts
bot.onAction(["forward", "spam", "edit"], async (event) => {
const submissionId = event.value;
if (!submissionId || !event.thread) return;
const formData = await getSubmission(submissionId);
if (!formData) return;
if (event.actionId === "edit") {
await event.openModal(editSubmissionModal(formData, submissionId));
} else {
const handler = event.actionId === "forward" ? handleForward : handleSpam;
await handler(event, formData, submissionId, event.thread.id);
}
});

The forward handler sends the email, optionally POSTs to a webhook endpoint, updates the Slack card, and cleans up Redis, all in parallel:

src/bot.ts
const handleForward = async (event, formData, submissionId, threadId) => {
const slack = bot.getAdapter("slack");
await Promise.all([
forwardToEmail(formData),
forwardToEndpoint(formData),
slack.editMessage(
threadId,
event.messageId,
forwardedCard(formData, event.user.fullName)
),
deleteSubmission(submissionId),
]);
};

After forwarding, the card is replaced with a read-only version showing who forwarded it and where. The submission is deleted from Redis since it's no longer needed.

You'll need accounts with three services:

  • Slack for the bot itself. Create a new app at api.slack.com/apps.
  • Redis for temporary submission storage. Any Redis provider works. Upstash supports serverless deployments and has a free tier.
  • Resend to send forwarded submissions via email. Sign up at resend.com and verify a sending domain.
  1. Create a new Slack app from a manifest at api.slack.com/apps. Use the slack-manifest.json file included in the template repo. Replace the two https://example.com URLs with your production domain (e.g. https://your-app.vercel.app/api/webhooks/slack).
  2. Install the app in your workspace and copy the Bot User OAuth Token.
  3. Copy the Signing Secret from the Basic Information page.

The template needs these environment variables:

.env.local
# Slack
SLACK_BOT_TOKEN=xoxb-...
SLACK_SIGNING_SECRET=...
SLACK_CHANNEL_ID=C...
# Redis
REDIS_URL=redis://...
# Resend
RESEND_API_KEY=re_...
RESEND_FROM_ADDRESS=bot@yourdomain.com
RESEND_FROM_NAME=Formbot # Optional, defaults to "Formbot"
# Forwarding
FORWARD_EMAIL=team@yourdomain.com # Where approved submissions are sent
FORWARD_ENDPOINT= # Optional: webhook URL for downstream systems

SLACK_CHANNEL_ID is the channel where submission cards will appear. You can find it by right-clicking a channel in Slack and selecting "View channel details" (the ID is at the bottom of the modal).

FORWARD_ENDPOINT is optional. If set, the bot will also POST the form data as JSON to that URL when a submission is forwarded. This is useful for piping approved submissions into a CRM, database, or another service.

Click the deploy button at the top of this guide, or clone the repo and deploy manually:

Terminal
git clone https://github.com/vercel-labs/chat-sdk-form-bot.git
cd chat-sdk-form-bot
pnpm install
vercel

After deploying, update your Slack app's interactivity request URL to point to your production domain: https://<your-vercel-domain>/api/webhooks/slack.

Send a test submission:

Terminal
curl -X POST https://<your-domain>/api/form \
-H "Content-Type: application/json" \
-d '{"Name": "Jane Doe", "Email": "jane@example.com", "Message": "Hello!"}'

You should see a JSON response in your terminal:

{"status":"received","submissionId":"a1b2c3d4-..."}

A card should appear in your configured Slack channel within a few seconds.

For local development, the template includes a Node.js server entrypoint:

Terminal
pnpm dev

This starts a local server at http://localhost:3000. To receive Slack webhooks locally, use ngrok to create a public tunnel:

Terminal
ngrok http 3000

Then update your Slack app's request URL to the ngrok URL (e.g. https://abc123.ngrok-free.dev/api/webhooks/slack).

Chat SDK supports multiple platforms from a single codebase. The cards, fields, and buttons you've already defined render natively on each platform: Block Kit on Slack, Adaptive Cards on Teams, Google Chat Cards, and so on.

To add Microsoft Teams or another platform, register an additional adapter:

src/bot.ts
import { createSlackAdapter } from "@chat-adapter/slack";
import { createTeamsAdapter } from "@chat-adapter/teams";
export const bot = new Chat({
adapters: {
slack: createSlackAdapter(),
teams: createTeamsAdapter(),
},
state,
userName: "form-bot",
});

The existing webhook route in src/index.ts already uses a :platform parameter, so Teams webhooks would be handled at /api/webhooks/teams with no additional routing.

You could also post to multiple platforms at once. For example, you might post form submissions to both a Slack channel and a Teams channel by calling bot.channel() with different platform prefixes:

src/bot.ts
const slackChannel = bot.channel(`slack:${process.env.SLACK_CHANNEL_ID}`);
const teamsChannel = bot.channel(`teams:${process.env.TEAMS_CHANNEL_ID}`);
await Promise.all([
slackChannel.post(newSubmissionCard(formData, submissionId)),
teamsChannel.post(newSubmissionCard(formData, submissionId)),
]);

Modals are currently Slack-only, so the Edit & Forward button only works on Slack. On other platforms, you'd want to either hide that button or replace it with a different editing flow.

See the Chat SDK adapter directory for the full list of supported platforms.

Was this helpful?

supported.