Last reviewed: September 12, 2026.

Direct answer

A CometAPI Slack bot connects two streaming lifecycles. Bolt for JavaScript receives a Slack event and provides a thread-aware sayStream helper. An OpenAI-compatible JavaScript client sends the user’s prompt to CometAPI with stream: true. An asynchronous loop takes each nonempty CometAPI text delta, appends it to the Slack stream, and stops the Slack stream when generation ends.

Keep both clients on the server. Your deployment adapter should create the Bolt app and the CometAPI client from credentials stored by the hosting platform, then export those already-configured objects to the listener. The client should use the base URL specified by the CometAPI Chat Completions documentation . That reference also defines the required model and messages fields and the SSE response shape. If you have not selected a current model, work through the model-catalog validation checklist first.

Install the runtime libraries:

npm install @slack/bolt openai

Create a deployment-specific module named deployment-adapter.js. It should export an initialized Bolt App, an initialized OpenAI-compatible client pointed at CometAPI, and a verified model name. Read all credentials from the platform’s secret store and never return them from a health endpoint or write them to a log. Keeping those host-specific details outside the listener also makes the streaming behavior easier to test with fake clients.

The application module below contains the complete event, streaming, cleanup, and logging workflow without embedding secret-shaped setup examples:

import {
  app,
  cometapi,
  selectedModel,
} from './deployment-adapter.js';

if (!selectedModel) {
  throw new Error('A CometAPI model name is required.');
}

const publicFailure =
  'The streamed reply stopped early. Please try again.';

function promptFromMention(text = '') {
  const firstSpace = text.indexOf(' ');
  return firstSpace === -1 ? '' : text.slice(firstSpace + 1).trim();
}

function classifyError(error) {
  const candidate =
    error !== null && typeof error === 'object' ? error : {};
  const nested =
    candidate.data !== null && typeof candidate.data === 'object'
      ? candidate.data
      : {};
  const status = Number.isInteger(candidate.status)
    ? candidate.status
    : null;
  const rawCode =
    typeof candidate.code === 'string'
      ? candidate.code
      : typeof nested.error === 'string'
        ? nested.error
        : '';
  const errorCode = /^[A-Za-z0-9_.-]{1,64}$/.test(rawCode)
    ? rawCode
    : 'unknown';

  return {
    error_name:
      typeof candidate.name === 'string'
        ? candidate.name.slice(0, 64)
        : 'Error',
    error_code: errorCode,
    http_status: status,
    retryable:
      status === 429 || (status !== null && status >= 500),
  };
}

app.event(
  'app_mention',
  async ({ event, say, sayStream }) => {
    const prompt = promptFromMention(event.text ?? '');
    const threadTs = event.thread_ts ?? event.ts;

    if (!prompt) {
      await say({
        text: 'Mention me with a question so I have something to answer.',
        thread_ts: threadTs,
      });
      return;
    }

    if (typeof sayStream !== 'function') {
      await say({
        text: 'Streaming is unavailable for this conversation.',
        thread_ts: threadTs,
      });
      return;
    }

    const startedAt = Date.now();
    let slackStream = null;
    let streamStopped = false;
    let chunkCount = 0;
    let characterCount = 0;

    try {
      const modelStream =
        await cometapi.chat.completions.create({
          model: selectedModel,
          messages: [
            {
              role: 'developer',
              content:
                'Answer clearly and keep Slack formatting simple.',
            },
            { role: 'user', content: prompt },
          ],
          stream: true,
        });

      slackStream = sayStream({ buffer_size: 100 });

      for await (const chunk of modelStream) {
        const text = chunk.choices[0]?.delta?.content;
        if (!text) continue;

        await slackStream.append({ markdown_text: text });
        chunkCount += 1;
        characterCount += text.length;
      }

      if (chunkCount === 0) {
        await slackStream.append({
          markdown_text: 'No text was returned for this request.',
        });
      }

      await slackStream.stop();
      streamStopped = true;

      app.logger.info('cometapi_slack_stream_complete', {
        source_ts: event.ts,
        channel_id: event.channel,
        thread_ts: threadTs,
        model: selectedModel,
        outcome: chunkCount > 0 ? 'complete' : 'empty',
        duration_ms: Date.now() - startedAt,
        chunk_count: chunkCount,
        character_count: characterCount,
      });
    } catch (error) {
      app.logger.error('cometapi_slack_stream_failed', {
        source_ts: event.ts,
        channel_id: event.channel,
        thread_ts: threadTs,
        model: selectedModel,
        outcome: 'failed',
        duration_ms: Date.now() - startedAt,
        chunk_count: chunkCount,
        character_count: characterCount,
        ...classifyError(error),
      });

      let failureReported = false;

      if (slackStream && !streamStopped) {
        try {
          await slackStream.append({
            markdown_text: `\n\n${publicFailure}`,
          });
          failureReported = true;
          await slackStream.stop();
          streamStopped = true;
        } catch (cleanupError) {
          app.logger.error('slack_stream_cleanup_failed', {
            source_ts: event.ts,
            channel_id: event.channel,
            thread_ts: threadTs,
            ...classifyError(cleanupError),
          });
        }
      }

      if (!failureReported) {
        try {
          await say({
            text: publicFailure,
            thread_ts: threadTs,
          });
        } catch (replyError) {
          app.logger.error('slack_error_reply_failed', {
            source_ts: event.ts,
            channel_id: event.channel,
            thread_ts: threadTs,
            ...classifyError(replyError),
          });
        }
      }
    }
  },
);

await app.start(Number(process.env.PORT ?? 3000));
app.logger.info('Bolt app is running.');

Awaiting every append call gives Slack time to accept each buffered update before the loop requests the next one. Optional access around choices[0] is equally important: CometAPI documents that a final usage chunk can contain an empty choices array.

Who this is for

This pattern is for JavaScript developers who have a Slack app or can configure one, want answers to stay in the thread where a user mentions the app, and prefer progressive output to a long silent wait. It is also useful to operators who need a small integration with observable success, empty-output, partial-failure, and cleanup paths.

The example deliberately remains single-turn. It does not automatically load prior Slack messages, retain conversation history, run tools, or apply application-specific authorization rules. Establish a reliable transport boundary before adding those features.

Key takeaways

  • Treat the CometAPI SSE response and Slack streaming message as separate lifecycles joined by an awaited asynchronous loop.
  • Send a verified model and a complete message array on every CometAPI request.
  • Ignore stream chunks that do not contain a text delta.
  • Let Bolt’s sayStream obtain the channel and thread context from the triggering event.
  • Stop every started Slack stream on success and make a best effort to stop it after a partial failure.
  • Log identifiers, counts, duration, status, and normalized error classification—not prompts, generated text, credentials, or raw errors.

Sources checked

Contract details to verify

Deployment boundary. The adapter should supply the already-configured Bolt app, CometAPI client, and model name. Keep credential loading in that adapter or the hosting platform, not in the event handler. Confirm that startup fails before accepting Slack events when required configuration is unavailable. Never include adapter inputs in logs.

CometAPI request. The Chat Completions contract requires model and messages. The example sends a developer instruction before the user’s prompt and enables incremental delivery with stream: true. Normal text arrives under choices[0].delta.content, but the loop must tolerate empty content, an empty delta, or an empty choices array. Iteration ends normally when the stream completes and throws when the client reports a failure.

Slack delivery. Bolt exposes sayStream to supported event and message listeners. The refetched guide says it derives the channel and thread timestamp from the event and can fall back to the event timestamp. It also derives recipient team and user identifiers. If the helper is unavailable because usable conversation context cannot be found, return a controlled message rather than inventing routing identifiers.

Slack scope and stream mode. The underlying start-stream method requires chat:write. Use markdown_text consistently for this implementation because Slack documents an error when markdown_text and chunks are supplied together. The start method lists a 12,000-character limit for its markdown_text argument and a Tier 2 rate class. The helper’s buffer keeps very small model deltas from being pushed individually.

Happy-path operator workflow. Install the app in a nonproduction Slack workspace and ensure its bot can write in the test conversation. Start the process and confirm the single startup log. Mention the app with a short question that should produce several sentences. Verify that one response appears in the originating thread, grows progressively, and finishes cleanly. Then verify one cometapi_slack_stream_complete record containing the source timestamp, channel ID, thread timestamp, model, outcome, duration, chunk count, and character count. Confirm that the prompt and generated answer are absent from the log.

Error-path operator workflow. In a local test, replace the returned provider stream with this deterministic fixture:

async function* partialFailureFixture() {
  yield {
    choices: [
      { delta: { content: 'Partial response.' } },
    ],
  };
  throw new Error('forced_test_failure');
}

Run the same mention test. The user should first see the partial text and then the stable interruption message in the same thread. The handler should attempt to stop the stream and emit cometapi_slack_stream_failed with outcome, error_name, error_code, http_status, retryable, duration, and partial counts. The log must not contain the prompt, partial output, or raw exception. Run a second error test in which stream creation throws before yielding; the handler should send the same stable failure text through say because no Slack stream exists yet.

Failure modes

Missing or invalid CometAPI request fields. The refetched CometAPI reference shows failures when the model name or messages field is missing. The startup check catches an absent model name, and the request constructor always supplies both required fields. For another 400-class response, inspect only the normalized status and error code, then compare the request structure with the reference.

CometAPI authentication or service failure. The documented examples include 401-class and 500-class failures. Correct deployment configuration for an authentication rejection. Treat a rate limit or server-side failure as potentially retryable, but use a bounded policy with jitter and a total deadline rather than retrying indefinitely. The client-timeout guide is a useful next implementation boundary.

Empty final CometAPI chunk. A usage chunk can contain no choices. Directly indexing the first choice can crash after otherwise successful generation. Optional access in the example skips that chunk safely.

No text returned. A stream can finish without a usable text delta. The example stops the Slack stream with a neutral message and logs an empty outcome so operators can distinguish it from a normal answer and a thrown failure.

Missing Slack scope or conversation access. Slack documents errors such as missing_scope, no_permission, not_in_channel, and channel_not_found. Verify installed scopes and conversation membership. Do not repeatedly retry configuration failures.

Invalid or restricted thread. A stale thread timestamp, locked thread, read-only channel, or non-threadable conversation can prevent delivery. Preserve routing from the triggering event and classify the error for operators instead of falling back to an unrelated conversation.

Slack rate limiting or temporary unavailability. Slack lists rate-limit and service-unavailable failures. Respect any retry timing supplied by Slack, cap attempts, and retain the original stream identity. Avoid starting a second visible answer unless the first stream is known not to exist.

Failure after partial output. Either side can fail after users have read part of the answer. Mark the operation failed, append a plain interruption notice when possible, stop the stream, and log partial counts. Never record success merely because some text appeared.

Cleanup failure. If appending the interruption notice or stopping the stream fails, record a separate sanitized cleanup event. This lets operators distinguish the original failure from a Slack cleanup failure without exposing sensitive configuration or message content.

FAQ

Does this create one Slack message for every model delta?

No. sayStream manages a streaming Slack message, while its buffer can combine small additions. The loop awaits each append and calls stop once when generation completes.

Why use sayStream instead of calling Slack’s stream methods directly?

The helper derives conversation, thread, team, and recipient context from the triggering event. Direct calls are appropriate when an application must control those identifiers itself, but they require additional mapping and validation.

Why skip chunks without text?

CometAPI can send lifecycle or usage chunks without a text delta. Skipping them prevents an exception and avoids empty Slack updates.

Can the bot remember earlier messages in the thread?

Yes, but this example is intentionally single-turn. Multi-turn behavior requires selecting the intended messages, applying retention rules, bounding the context, and including the resulting history in the next request. Slack thread history is not automatically added to the CometAPI message array by this listener.

Which model should the bot use?

Keep the model name configurable and verify that it is currently available for Chat Completions. This avoids depending on an unverified or retired model alias.

Which fields are safe to log?

Use a source timestamp, channel and thread identifiers, model name, outcome, duration, chunk and character counts, HTTP status, normalized error code, and retryable flag. Exclude user text, generated output, credentials, client configuration, and raw error bodies. If local policy treats workspace identifiers as sensitive, pseudonymize them before storage.

What should users see when generation fails?

They should see a short, stable message in the same thread. Detailed classification belongs in sanitized operator logs rather than the public Slack reply.

Reader next step

Run the happy-path test and both forced-failure variants in a nonproduction workspace. Confirm thread placement, progressive output, final stream closure, and sanitized logs before increasing traffic. Next, add a total request deadline using the CometAPI timeout pattern .

After the transport is stable, add repeatable prompt cases and assertions instead of testing only by eye. The CometAPI Promptfoo test-suite tutorial provides the next testing layer.