惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Securelist
C
Cybersecurity and Infrastructure Security Agency CISA
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
S
Security Affairs
Hacker News: Ask HN
Hacker News: Ask HN
L
Lohrmann on Cybersecurity
PCI Perspectives
PCI Perspectives
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
C
Cyber Attacks, Cyber Crime and Cyber Security
Recent Commits to openclaw:main
Recent Commits to openclaw:main
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
MyScale Blog
MyScale Blog
月光博客
月光博客
W
WeLiveSecurity
T
Threat Research - Cisco Blogs
Martin Fowler
Martin Fowler
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Recorded Future
Recorded Future
The GitHub Blog
The GitHub Blog
Webroot Blog
Webroot Blog
Security Archives - TechRepublic
Security Archives - TechRepublic
TaoSecurity Blog
TaoSecurity Blog
P
Proofpoint News Feed
Google DeepMind News
Google DeepMind News
F
Full Disclosure
U
Unit 42
Jina AI
Jina AI
博客园 - 司徒正美
阮一峰的网络日志
阮一峰的网络日志
L
LINUX DO - 最新话题
宝玉的分享
宝玉的分享
大猫的无限游戏
大猫的无限游戏
The Hacker News
The Hacker News
The Last Watchdog
The Last Watchdog
T
Troy Hunt's Blog
腾讯CDC
T
Threatpost
H
Hacker News: Front Page
P
Palo Alto Networks Blog
博客园 - 聂微东
Last Week in AI
Last Week in AI
有赞技术团队
有赞技术团队
Help Net Security
Help Net Security
L
LINUX DO - 热门话题
N
News and Events Feed by Topic
人人都是产品经理
人人都是产品经理
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Spread Privacy
Spread Privacy

Chat SDK Documentation

Vercel Connect | Chat SDK Teams Low-Level APIs | Chat SDK CLI | Chat SDK Platform Adapters | Chat SDK State Adapters | Chat SDK Cards | Chat SDK Getting Started | Chat SDK Introduction | Chat SDK Modals | Chat SDK Slack Low-Level APIs | Chat SDK Streaming | Chat SDK Testing | Chat SDK Overview | Chat SDK toAiMessages | Chat SDK Cards | Chat SDK Overview | Chat SDK Markdown | Chat SDK Modals | Chat SDK AI SDK Tools | Chat SDK Types | Chat SDK Message Subject | Chat SDK Conversation History | Chat SDK Transcripts | Chat SDK Slack bot with Next.js and Redis Actions | Chat SDK Direct Messages | Chat SDK Emoji | Chat SDK Error Handling | Chat SDK File Uploads | Chat SDK Handling Events | Chat SDK Posting Messages | Chat SDK Slash Commands | Chat SDK State Adapters | Chat SDK Threads, Messages, and Channels | Chat SDK Creating a Chat Instance | Chat SDK WhatsApp Business Cloud Channel | Chat SDK Chat | Chat SDK Platform Adapters | Chat SDK Overlapping Messages | Chat SDK Ephemeral Messages | Chat SDK Message | Chat SDK Thread | Chat SDK Building a community adapter | Chat SDK Documenting your adapter | Chat SDK Publishing your adapter | Chat SDK Testing adapters | Chat SDK Code review GitHub bot with Hono and Redis Discord support bot with Nuxt and Redis Durable chat sessions with Next.js, Workflow, and Redis PostableMessage | Chat SDK
Schedule Slack posts with Next.js, Workflow, and Neon
Vercel · 2026-04-06 · via Chat SDK Documentation

This guide walks through scheduling Slack channel posts with Chat SDK, persisting schedules in Neon, and using Workflow sleep timers for durable delivery.

Chat SDK already supports native scheduled messages on Slack with thread.schedule() and channel.schedule(). That is useful for simple Slack-only cases.

This guide uses a different pattern: Workflow owns the timer with sleep(), and Neon stores the schedule as your source of truth. That gives you a more durable scheduling system you can extend with cancellation, rescheduling, approvals, and cross-platform delivery later.

The example uses Slack slash commands to schedule top-level channel posts.

  • Node.js 18+
  • pnpm (or npm/yarn)
  • A Next.js App Router project
  • A Slack workspace where you can install apps
  • A Neon Postgres database

If you still need Slack app setup and the webhook route, start with Slack bot with Next.js and Redis, then come back here and swap the state adapter to PostgreSQL.

Install Chat SDK, the Slack adapter, the PostgreSQL state adapter, Workflow, and pg:

pnpm add chat @chat-adapter/slack @chat-adapter/state-pg workflow pg

Create a Neon project, copy its pooled Postgres connection string, and store it as POSTGRES_URL.

createPostgresState() already supports POSTGRES_URL and DATABASE_URL, so the same Neon database can back both Chat SDK state and your app's schedule table.

Create a .env.local file:

SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
POSTGRES_URL=postgresql://user:password@your-neon-host-pooler.neon.tech/neondb?sslmode=require

Wrap your Next.js config with withWorkflow() so "use workflow" and "use step" directives are compiled:

import { withWorkflow } from "workflow/next";
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // ...your existing config
};

export default withWorkflow(nextConfig);

If your app uses proxy.ts, exclude .well-known/workflow/ from the matcher so Workflow's internal routes are not intercepted.

Add two slash commands to your Slack app and point both at your webhook route:

features:
  slash_commands:
    - command: /remind
      url: https://your-domain.com/api/webhooks/slack
      description: Schedule a channel post
      usage_hint: "<ISO-8601 timestamp> <message>"
      should_escape: false
    - command: /remind-cancel
      url: https://your-domain.com/api/webhooks/slack
      description: Cancel a scheduled post
      usage_hint: "<schedule-id>"
      should_escape: false

If you're starting from scratch rather than the Slack guide, make sure your app also has the commands and chat:write bot scopes in OAuth & Permissions, then reinstall the app after changing scopes.

This guide keeps parsing simple by accepting an ISO timestamp like 2026-03-15T09:00:00Z.

Run this SQL in the Neon SQL Editor:

CREATE TABLE scheduled_posts (
  id text PRIMARY KEY,
  channel_id text NOT NULL,
  message text NOT NULL,
  post_at timestamptz NOT NULL,
  status text NOT NULL CHECK (status IN ('pending', 'sent', 'canceled')),
  workflow_run_id text,
  created_by text NOT NULL,
  sent_at timestamptz,
  canceled_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX scheduled_posts_status_post_at_idx
  ON scheduled_posts (status, post_at);

Create the Postgres client

Use a shared pg pool for your own schedule records:

import pg from "pg";

if (!process.env.POSTGRES_URL) {
  throw new Error("POSTGRES_URL is required");
}

export const pool = new pg.Pool({
  connectionString: process.env.POSTGRES_URL,
});

Use Neon for Chat SDK state by switching to the PostgreSQL state adapter:

import { createSlackAdapter } from "@chat-adapter/slack";
import { createPostgresState } from "@chat-adapter/state-pg";
import { Chat } from "chat";

export const bot = new Chat({
  userName: "reminder-bot",
  adapters: {
    slack: createSlackAdapter(),
  },
  state: createPostgresState(),
});

Create a small data layer for inserting, loading, updating, and canceling scheduled posts:

import { randomUUID } from "node:crypto";
import { pool } from "@/lib/db";

export interface ScheduledPost {
  id: string;
  channelId: string;
  message: string;
  postAt: string;
  status: "pending" | "sent" | "canceled";
  workflowRunId: string | null;
}

export async function createScheduledPost(input: {
  channelId: string;
  createdBy: string;
  message: string;
  postAt: Date;
}) {
  const id = randomUUID();

  await pool.query(
    `INSERT INTO scheduled_posts (
      id, channel_id, message, post_at, status, created_by
    ) VALUES ($1, $2, $3, $4, 'pending', $5)`,
    [id, input.channelId, input.message, input.postAt, input.createdBy]
  );

  return { id, ...input, postAt: input.postAt.toISOString() };
}

export async function getScheduledPost(id: string): Promise<ScheduledPost | null> {
  const result = await pool.query(
    `SELECT id, channel_id, message, post_at, status, workflow_run_id
     FROM scheduled_posts
     WHERE id = $1
     LIMIT 1`,
    [id]
  );

  if (result.rows.length === 0) {
    return null;
  }

  const row = result.rows[0];
  return {
    id: row.id as string,
    channelId: row.channel_id as string,
    message: row.message as string,
    postAt: (row.post_at as Date).toISOString(),
    status: row.status as ScheduledPost["status"],
    workflowRunId: (row.workflow_run_id as string | null) ?? null,
  };
}

export async function attachWorkflowRun(id: string, runId: string) {
  await pool.query(
    `UPDATE scheduled_posts
     SET workflow_run_id = $2
     WHERE id = $1`,
    [id, runId]
  );
}

export async function markScheduledPostSent(id: string) {
  await pool.query(
    `UPDATE scheduled_posts
     SET status = 'sent', sent_at = now()
     WHERE id = $1 AND status = 'pending'`,
    [id]
  );
}

export async function cancelScheduledPost(id: string, createdBy: string) {
  const result = await pool.query(
    `UPDATE scheduled_posts
     SET status = 'canceled', canceled_at = now()
     WHERE id = $1 AND created_by = $2 AND status = 'pending'`,
    [id, createdBy]
  );

  return (result.rowCount ?? 0) > 0;
}

The workflow only orchestrates. All Postgres access and Slack posting stay in "use step" functions:

import { sleep } from "workflow";
import { bot } from "@/lib/bot";
import {
  getScheduledPost,
  markScheduledPostSent,
} from "@/lib/scheduled-posts";

async function loadScheduledPost(scheduleId: string) {
  "use step";
  return getScheduledPost(scheduleId);
}

async function postToChannel(channelId: string, message: string) {
  "use step";

  await bot.initialize();

  const channel = bot.channel(channelId);
  await channel.post(message);
}

async function completeScheduledPost(scheduleId: string) {
  "use step";
  await markScheduledPostSent(scheduleId);
}

export async function sendScheduledPost(scheduleId: string) {
  "use workflow";

  const schedule = await loadScheduledPost(scheduleId);
  if (!schedule || schedule.status !== "pending") {
    return;
  }

  await sleep(new Date(schedule.postAt));

  // Re-check the row after waking up in case it was canceled.
  const fresh = await loadScheduledPost(scheduleId);
  if (!fresh || fresh.status !== "pending") {
    return;
  }

  await postToChannel(fresh.channelId, fresh.message);
  await completeScheduledPost(scheduleId);
}

This is the key Workflow pattern:

  • schedule creation writes a row in Neon first
  • the workflow sleeps until postAt
  • the workflow reloads the row after waking up
  • canceled schedules no-op instead of posting

That makes cancellation simple and durable.

Create a side-effect module that parses commands, writes rows to Neon, starts workflows, and handles cancellation:

import { start } from "workflow/api";
import { bot } from "@/lib/bot";
import {
  attachWorkflowRun,
  cancelScheduledPost,
  createScheduledPost,
} from "@/lib/scheduled-posts";
import { sendScheduledPost } from "@/workflows/send-scheduled-post";

function parseReminderInput(input: string) {
  const trimmed = input.trim();
  const firstSpace = trimmed.indexOf(" ");

  if (firstSpace === -1) {
    return null;
  }

  const timestamp = trimmed.slice(0, firstSpace);
  const message = trimmed.slice(firstSpace + 1).trim();
  const postAt = new Date(timestamp);

  if (!message || Number.isNaN(postAt.getTime()) || postAt <= new Date()) {
    return null;
  }

  return { message, postAt };
}

bot.onSlashCommand("/remind", async (event) => {
  const parsed = parseReminderInput(event.text);

  if (!parsed) {
    await event.channel.postEphemeral(
      event.user,
      "Usage: `/remind 2026-03-15T09:00:00Z Review launch checklist`",
      { fallbackToDM: false }
    );
    return;
  }

  const scheduled = await createScheduledPost({
    channelId: event.channel.id,
    createdBy: event.user.userId,
    message: parsed.message,
    postAt: parsed.postAt,
  });

  const run = await start(sendScheduledPost, [scheduled.id]);
  await attachWorkflowRun(scheduled.id, run.runId);

  await event.channel.postEphemeral(
    event.user,
    `Scheduled \`${scheduled.id}\` for ${parsed.postAt.toISOString()}.`,
    { fallbackToDM: false }
  );
});

bot.onSlashCommand("/remind-cancel", async (event) => {
  const scheduleId = event.text.trim();

  if (!scheduleId) {
    await event.channel.postEphemeral(
      event.user,
      "Usage: `/remind-cancel <schedule-id>`",
      { fallbackToDM: false }
    );
    return;
  }

  const canceled = await cancelScheduledPost(scheduleId, event.user.userId);

  await event.channel.postEphemeral(
    event.user,
    canceled
      ? `Canceled \`${scheduleId}\`.`
      : `No pending schedule found for \`${scheduleId}\`.`,
    { fallbackToDM: false }
  );
});

The cancellation path is intentionally simple. It only updates the database row, and it only allows the creator of the schedule to cancel it. If the workflow wakes up later, it re-checks the row and exits without posting.

If you want workspace admins or moderators to cancel other users' schedules, extend the cancellation query with your own permission rules instead of removing the ownership check.

Import the handler module once so the slash command handlers are registered:

import "@/lib/reminder-handlers";
import { after } from "next/server";
import { bot } from "@/lib/bot";

type Platform = keyof typeof bot.webhooks;

export async function POST(
  request: Request,
  context: RouteContext<"/api/webhooks/[platform]">
) {
  const { platform } = await context.params;

  const handler = bot.webhooks[platform as Platform];
  if (!handler) {
    return new Response(`Unknown platform: ${platform}`, { status: 404 });
  }

  return handler(request, {
    waitUntil: (task) => after(() => task),
  });
}
  1. Start your development server with pnpm dev
  2. Expose it with a tunnel such as ngrok http 3000
  3. Update your Slack slash command URLs to the tunnel URL
  4. In Slack, run /remind 2026-03-15T09:00:00Z Review launch checklist
  5. Confirm that you receive an ephemeral confirmation with a schedule ID
  6. Wait for the scheduled time and verify the bot posts to the channel
  7. Create another one and cancel it with /remind-cancel <schedule-id>

From here you can add:

  • rescheduling by canceling the old row and creating a new one
  • recurring posts by having the workflow create the next schedule before it exits
  • approvals by pausing the workflow with a hook before posting
  • thread reminders by storing thread.toJSON() instead of channelId and restoring it with bot.reviver()