Chat SDK with Edgenivo Daemons
Process work from a Chat SDK bot in a Edgenivo Daemon, then post the result back to the original conversation. Edgenivo Connect manages the Slack credentials and forwards verified events to your webhook. The webhook adds jobs to Edgenivo Queues, and a daemon polls for jobs and replies through Chat SDK.
Use a daemon for a continuously running consumer, such as a report generator or a worker that depends on system tools packaged in its container. Keep the webhook in Edgenivo Functions, because daemons don't accept inbound HTTP or TCP traffic.
For tasks that need persisted steps, retries, or human approval, use Edgenivo Workflows. Daemons run only in production deployments during the beta. See Daemons limits before choosing a deployment setup.
Complete the Chat SDK quickstart with Slack, Edgenivo Connect, and Redis. Use one Slack workspace installation for this example, and keep the quickstart's webhook route and Connect trigger configuration.
The webhook and daemon run in the same Edgenivo project and use the same SLACK_CONNECTOR and REDIS_URL environment variables. The connector must allow token access from the project's Production environment. You don't need SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, or SLACK_APP_TOKEN for this setup.
Install the Queues SDK in the bot project:
pnpm add @vercel/queueReplace src/lib/bot.ts from the quickstart with the following configuration and handlers. The webhook continues to import bot from this module and use its framework's waitUntil support:
import { createHash } from 'node:crypto';
import { Chat, type Message, type Thread } from 'chat';
import { createSlackAdapter } from '@chat-adapter/slack';
import { createRedisState } from '@chat-adapter/state-redis';
import { connectSlackAdapter } from '@vercel/connect/chat';
import { QueueClient } from '@vercel/queue';
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(),
});
const queue = new QueueClient({ region: 'iad1', deploymentId: null });
async function enqueue(thread: Thread, message: Message) {
await thread.subscribe();
const idempotencyKey = createHash('sha256')
.update(JSON.stringify([thread.id, message.id]))
.digest('hex');
await queue.send(
'chat-jobs',
{ threadId: thread.id, text: message.text },
{ idempotencyKey },
);
}
bot.onNewMention(enqueue);
bot.onSubscribedMessage(enqueue);
Awaiting queue.send() persists the job before the event handler finishes. The idempotency key identifies the originating chat message if the publisher sends it again. The daemon receives only the thread ID and message text, so it can restore the thread without serializing a Chat SDK instance.
Both queue clients use iad1 and deploymentId: null. The fixed region lets the worker receive the messages the webhook sends. Unpinning the deployment lets new daemon versions process the existing backlog. Keep the topic, region, consumer group, and message format compatible across deployments. See Queue versioning.
From the project root, create a separate package for the daemon. Use Node.js 24 or later:
mkdir -p workers/chat-bot
cd workers/chat-bot
pnpm init --init-type module --init-package-managerAdd pnpm-workspace.yaml in the worker directory to make the package independent of the web app and select the OIDC helper used by its dependencies:
packages:
- '.'
overrides:
'@vercel/oidc': 4.0.5Daemons receive a rotating token through VERCEL_OIDC_TOKEN_FILE. The override ensures that both Connect and Queues use an OIDC helper that reads the token file. Older Queues dependencies read only the environment token and can't authenticate from a daemon.
Install the worker's dependencies:
pnpm add chat @chat-adapter/slack @chat-adapter/state-redis @vercel/connect @vercel/queueCommit package.json, pnpm-workspace.yaml, and pnpm-lock.yaml from the worker directory. The container uses these files to install the same dependencies.
Create worker.ts or worker.js in the worker directory:
import { setTimeout as sleep } from 'node:timers/promises';
import { Chat } from 'chat';
import { createSlackAdapter } from '@chat-adapter/slack';
import { createRedisState } from '@chat-adapter/state-redis';
import { connectSlackAdapter } from '@vercel/connect/chat';
import { PollingQueueClient } from '@vercel/queue';
interface ChatJob {
threadId: string;
text: string;
}
const connector = process.env.SLACK_CONNECTOR;
if (!connector) throw new Error('Set SLACK_CONNECTOR to your connector UID.');
const state = createRedisState();
const bot = new Chat({
userName: 'my-bot',
adapters: {
slack: createSlackAdapter({ ...connectSlackAdapter(connector) }),
},
state,
});
const queue = new PollingQueueClient({ region: 'iad1', deploymentId: null });
const shutdown = new AbortController();
process.once('SIGTERM', () => shutdown.abort());
process.once('SIGINT', () => shutdown.abort());
try {
await bot.initialize();
console.log('Chat worker ready.');
while (!shutdown.signal.aborted) {
try {
const result = await queue.receive<ChatJob>(
'chat-jobs',
'chat-replies',
async (job, metadata) => {
const key = `chat-jobs:completed:${metadata.messageId}`;
if (await state.get(key)) return;
await bot
.thread(job.threadId)
.post(`Processed by a Edgenivo Daemon: ${job.text}`);
const ttl = Math.max(1000, metadata.expiresAt.getTime() - Date.now());
await state.set(key, true, ttl);
console.log(`Completed job ${metadata.messageId}.`);
},
{ limit: 1 },
);
if (result.ok) continue;
} catch (error) {
console.error('Could not process a chat job:', error);
}
await sleep(1000, undefined, { signal: shutdown.signal }).catch(() => {});
}
} finally {
await bot.shutdown();
}
console.log('Chat worker stopped.');
The worker initializes Chat SDK without registering inbound event handlers or opening a Socket Mode connection. connectSlackAdapter() resolves the bot token through Connect when the adapter calls Slack. The sample echoes the queued text to confirm the complete path; replace that processing with your report generation or other background work.
receive() returns after the handler and queue acknowledgment finish. The SDK extends the message's visibility while processing it and acknowledges it only when the handler succeeds. If posting the reply or saving the completion marker fails, the job remains eligible for redelivery. See Poll mode.
The Redis marker lets a redelivered completed job skip posting another reply. It expires with the queue message. A crash after posting to Slack but before saving that marker can still produce a duplicate reply. Queue delivery is at-least-once; design external writes for retries rather than relying on exactly-once execution.
On SIGTERM or SIGINT, the worker stops starting new polls and waits for its current receive() call before closing Redis. Choose a shutdown grace period that gives active work time to finish, and make interrupted jobs safe to retry after the process stops.
Add a Dockerfile in the worker directory:
FROM node:24-slim
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --prod --frozen-lockfile
COPY worker.ts ./
USER node
CMD ["node", "worker.ts"]Node.js 24 runs this TypeScript example directly. For the JavaScript version, replace worker.ts with worker.js in both COPY and CMD. The exec-form CMD lets Node.js receive shutdown signals directly.
Add the daemon to vercel.json in your project root, keeping any existing configuration:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"daemons": {
"chat-worker": {
"root": "workers/chat-bot/",
"entrypoint": "Dockerfile",
"instances": { "iad1": 1 },
"terminationGracePeriodSeconds": 60
}
}
}This configuration starts one worker and gives it 60 seconds to shut down. It uses the same queue region as the webhook. All instances use the chat-replies consumer group so they share the work. Deployments can overlap briefly; shared Redis state and retry-safe processing remain necessary even with one configured instance.
Confirm that SLACK_CONNECTOR and REDIS_URL are configured for the project's Production environment. Daemons inherit the project's environment variables, and the SDKs authenticate through the daemon's OIDC token file. Keep the connector UID out of the queue payload; credentials come from the worker's configuration.
Create a production deployment from the project root:
vercel deploy --prodVerify the flow:
- Open the project's Daemons tab and confirm that
chat-workeris running. Check runtime logs forChat worker ready. - Mention the bot in the Slack workspace connected through Connect. The webhook should enqueue a job, and the daemon should post
Processed by a Edgenivo Daemon:followed by your message in the same thread. - Reply in that thread without mentioning the bot. The subscription handler should enqueue another job and produce another reply.
- Stop the daemon and send another message. The job should remain queued. Restore one instance and confirm the reply arrives.
- Restart the daemon and check for
Chat worker stopped.in the old instance's logs. Inspect failures and redeliveries with Queues observability.
Connect forwards Slack events to deployed URLs. For local worker testing, use a separate project, connector, and Redis database, then follow Daemons local development and Connect authentication. A local process that polls the same project, environment, topic, and consumer group can claim jobs intended for the deployed worker.
A daemon can also run a Slack Socket Mode bot over an outbound WebSocket. This requires an app-level token (xapp-…) with the connections:write scope, in addition to the bot token (xoxb-…).
Connect's Slack helper supplies the bot token and verifies forwarded HTTP events. It doesn't supply the app-level token required by Socket Mode, and the Connect-created Slack app doesn't expose that token. Use an app you create and control in Slack for Socket Mode. Requesting an app subject in Connect selects application credentials; it doesn't request Slack's app-level token type.
Keep the Connect-backed webhook and queue flow above when using a Slack app created through Connect. See Connect delivery limitations.
Was this helpful?