Skip to content
Docs

Chat SDK with Edgenivo Workflows

Use Edgenivo Workflows to pause a task from a chat message until a person approves an action. Use Chat SDK approval cards and verified button callbacks to resume the workflow after approval or denial, or let it expire after a timeout.

Workflow SDK is the open-source library you use to write workflows in JavaScript or TypeScript. Edgenivo Workflows runs those workflows on Edgenivo, managing execution and persistence and providing observability in the Edgenivo dashboard. You write the approval logic with the SDK, then deploy the bot to Edgenivo to run it on the managed service.

Use the Next.js project from the Chat SDK quickstart, with Slack, Edgenivo Connect, and Redis configured. This example uses Slack interactive cards; it does not use the Web Adapter's text-only browser stream.

Choose the Slack user IDs allowed to approve and store them as a comma-separated CHAT_APPROVER_IDS environment variable. Use IDs from the workspace where the bot is installed. Do not derive the approver list from the incoming message.

Install the workflow package to define workflows and steps with Workflow SDK:

Terminal
pnpm add workflow

Wrap your existing Next.js configuration with withWorkflow:

next.config.ts
import { withWorkflow } from 'workflow/next';
import type { NextConfig } from 'next';
 
const nextConfig: NextConfig = {};
 
export default withWorkflow(nextConfig);

Keep your existing configuration fields. If you use Proxy or middleware, exclude /.well-known/workflow/ from its matcher. See Workflow SDK with Next.js for framework setup and deployment requirements.

Move your existing Chat instance into src/lib/chat.ts, leaving event handlers in src/lib/bot.ts. Keep the shared configuration in this module:

src/lib/chat.ts
import { Chat } from 'chat';
import { createSlackAdapter } from '@chat-adapter/slack';
import { createRedisState } from '@chat-adapter/state-redis';
import { connectSlackAdapter } from '@vercel/connect/chat';
 
const connector = process.env.SLACK_CONNECTOR;
if (!connector) throw new Error('Set SLACK_CONNECTOR to your connector UID.');
 
export const bot = new Chat({
  userName: 'my-bot',
  adapters: {
    slack: createSlackAdapter({ ...connectSlackAdapter(connector) }),
  },
  state: createRedisState(),
});

Workflow steps import this configuration and call initialize() before using Redis or an adapter. Pass the thread ID between steps and restore the thread with bot.thread(), so each step can run in a fresh server process.

Create src/workflows/approve-announcement.ts. The workflow waits durably for a callback. Each step initializes the bot before posting or editing a message:

src/workflows/approve-announcement.ts
import { buildApprovalCard, buildResolvedCard } from 'chat/workflow';
import { createWebhook, sleep } from 'workflow';
 
const title = 'Publish this announcement?';
 
export async function approveAnnouncement(
  threadId: string,
  announcement: string,
  approvers: string[],
) {
  'use workflow';
 
  using webhook = createWebhook();
  let messageId = await postCard(threadId, announcement, webhook.url);
  const timeout = sleep('24h').then(() => null);
  const clicks = webhook[Symbol.asyncIterator]();
  let approved = false;
  let outcome = 'Approval timed out. Nothing was published.';
 
  while (true) {
    const next = await Promise.race([clicks.next(), timeout]);
    if (next === null || next.done) break;
 
    const payload = await next.value.json().catch(() => null);
    if (
      payload?.type !== 'action' ||
      typeof payload.user?.id !== 'string' ||
      !['approve', 'deny'].includes(payload.actionId)
    )
      continue;
 
    if (!approvers.includes(payload.user.id)) {
      await postNotice(
        threadId,
        "You aren't authorized to decide this approval.",
      );
      messageId = await postCard(
        threadId,
        announcement,
        webhook.url,
        messageId,
      );
      continue;
    }
 
    approved = payload.actionId === 'approve';
    outcome = approved
      ? `Approved by ${payload.user.id}.`
      : 'Announcement denied. Nothing was published.';
    break;
  }
 
  await finishApproval(threadId, messageId, announcement, outcome, approved);
}
 
async function postCard(
  threadId: string,
  announcement: string,
  callbackUrl: string,
  previousMessageId?: string,
) {
  'use step';
  const { bot } = await import('../lib/chat');
  await bot.initialize();
  const thread = bot.thread(threadId);
  if (previousMessageId) {
    await thread.adapter.editMessage(
      threadId,
      previousMessageId,
      buildResolvedCard(
        { title, description: announcement },
        'Still pending. Use the new approval card.',
      ),
    );
  }
  const message = await thread.post(
    buildApprovalCard({ title, description: announcement }, callbackUrl),
  );
  return message.id;
}
 
async function postNotice(threadId: string, text: string) {
  'use step';
  const { bot } = await import('../lib/chat');
  await bot.initialize();
  await bot.thread(threadId).post(text);
}
 
async function finishApproval(
  threadId: string,
  messageId: string,
  announcement: string,
  outcome: string,
  approved: boolean,
) {
  'use step';
  const { bot } = await import('../lib/chat');
  await bot.initialize();
  const thread = bot.thread(threadId);
  await thread.adapter.editMessage(
    threadId,
    messageId,
    buildResolvedCard({ title, description: announcement }, outcome),
  );
  await thread.post(
    approved ? `Approved announcement: ${announcement}` : outcome,
  );
}

Chat SDK stores the card's callback URL in Redis and forwards button events after the Slack webhook verifies them. The workflow checks the verified user's ID against the server-provided approver list. An unauthorized click leaves the approval pending and posts a replacement card because callback buttons are single-use. When the workflow finishes, using disposes the webhook and the resolved card has no buttons.

The wait does not keep the original webhook function running. Chat SDK's requestApproval helper provides the same approval pattern with less workflow code when your application registers and initializes a Chat singleton in each step's runtime. This example uses explicit steps and thread IDs to control initialization across Next.js server bundles.

Replace the echo handlers in src/lib/bot.ts with:

src/lib/bot.ts
import { start } from 'workflow/api';
import { approveAnnouncement } from '../workflows/approve-announcement';
import { bot } from './chat';
 
export { bot };
 
bot.onNewMention(async (thread) => {
  const approvers = (process.env.CHAT_APPROVER_IDS ?? '')
    .split(',')
    .map((id) => id.trim())
    .filter(Boolean);
  if (approvers.length === 0) {
    throw new Error('Set CHAT_APPROVER_IDS to the allowed Slack user IDs.');
  }
 
  await start(approveAnnouncement, [
    thread.id,
    'Release v1.2.3 is ready for rollout.',
    approvers,
  ]);
});

Keep the existing webhook route importing bot from this module and passing Next.js after to the Slack handler. Await start to persist the workflow run, then let the event handler finish. On Edgenivo, Edgenivo Workflows persists the thread ID and approval inputs for later steps.

Deploy the app to Edgenivo. The workflows defined with Workflow SDK run on Edgenivo Workflows alongside your bot. Mention the bot in Slack to start a run and receive an approval card with the sample announcement.

  • Select Approve as an allowed user. The card records approval and the bot posts the announcement.
  • Start another run and select Deny. The bot confirms that nothing was published.
  • For a timeout check, temporarily change sleep('24h') to sleep('30s'), deploy, and leave a new card unanswered. The bot should report that approval timed out.
  • A user outside CHAT_APPROVER_IDS should be unable to resolve the approval.

Open your project in the Edgenivo dashboard and use Edgenivo Workflows observability to inspect the run, its steps, and logs. Before replacing the final message with an external write, make that step idempotent so retries do not repeat the action.


Was this helpful?

supported.