Wiring an AI bot into Slack over API
Slack expects a reply to a command within three seconds, and a model can take longer. How to work around it with response_url, with working Bolt code.
4 min · · Updated · development
A Slack bot on your own key is really a protocol problem, not a technical one: Slack requires a command to be acknowledged within 3 seconds, and a model, especially with extended thinking enabled, can easily take longer to think than that. Calling the model synchronously inside the command handler routinely produces an "operation_timeout" on Slack's side, even when the model's answer eventually arrives fine.
The pattern: acknowledge instantly, reply later via response_url
Slack's Events API and slash commands give you a response_url — a separate webhook you can post a message to after the handler has already returned an instant ack. That's the way around the timeout:
// slack-handler.js (Express + Slack Bolt)
import { App } from "@slack/bolt";
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET
});
app.command("/ask", async ({ command, ack, respond }) => {
await ack(); // acknowledge immediately, Slack only gives you 3 seconds
try {
const r = await fetch("https://api.zukko.pro/v1/messages", {
method: "POST",ack() reaches Slack instantly, while the actual call to api.zukko.pro and the follow-up respond() through response_url can take however long they need — Slack no longer bounds that part.
The OpenAI-compatible version, if you'd rather use that
Same logic against /v1/chat/completions, only the body and the reply path differ:
const r = await fetch("https://api.zukko.pro/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.ZUKKO_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "gpt-5.6-sol",
messages: [{ role: "user", content: command.text }]
})
});
const data = await r.json();
await respond(data.choices[0].message.content);Monitoring balance at the workspace level
If the bot lives in a work workspace and the whole team uses it, it's worth a daily job that checks what's left:
curl https://api.zukko.pro/v1/balance \
-H "Authorization: Bearer fetch_your_key"and posts a warning to an internal channel once the balance drops below some threshold. Catching that ahead of time beats getting a string of 402 insufficient_balance from the bot during working hours, right when people are actually using it.
Errors worth telling apart in the bot's reply
- 401 — a bad or expired key. For a Slack bot this usually means the key's environment variable never made it to production — worth checking at deploy time, not just after the first user complaint.
- 403 model_not_allowed_for_key — the key has an allowed-model list configured (on its dashboard page) and the bot is calling something outside it. Worth keeping the bot's model list and the key's allow-list in sync.
- 429 with
key_daily_limit_reached— an active team channel can easily hit its own key's daily cap (resets at 00:00 UTC; the monthly one iskey_monthly_limit_reached). The gateway has no rate limit of its own, so any other 429 comes from the provider. The message shown in Slack should read differently from "model unavailable." - 503
model_disabled— the model has been switched off. Worth logging it and updatingmodelin the bot's code (the current list is atGET https://api.zukko.pro/v1/models) rather than waiting for someone to notice it stopped answering.
Costing out a command
Every regular (non-streaming) response from /v1/messages or /v1/chat/completions carries an X-Zukko-Cost-Micros header — that specific call's cost in micro-dollars; streamed responses (stream: true) do not have it. If the bot is shared across several channels or teams in a company, log that header alongside the channel name and the user who triggered the command — then at month's end you can see which team spent what. /v1/usage cannot give you that split: its history covers the whole account, with no per-key filter.
Cache repeated commands
If /ask gets called with the same text often (a templated "translate this" command in a thread, say), turn on caching with an explicit X-Zukko-Cache: true header — that's the primary way, and it works for any model. temperature: 0 also enables the cache, but only for models that accept that value: newer Claude models (claude-sonnet-5, claude-opus-5-5) may reject a temperature other than 1 with a 400, so it's best not to pass temperature to them at all. A repeat of a request with an exactly identical body (same text, model and parameters — "nearly identical" requests do not count) within 7 days comes back instantly and free, with X-Zukko-Cache: hit in the response. Such cache hits are not recorded in /v1/usage.
An Idempotency-Key for redelivered events
Slack retries delivering events and commands if your server doesn't respond fast enough to the original HTTP request — a separate protocol detail, unrelated to response_url. If the handler ends up retried while the background call to the model is still running, without protection you can get billed twice for one user action. Add an Idempotency-Key to the model request itself — unique for each user action and identical when that action is redelivered. The Slack command id command.trigger_id fits; do not generate the key afresh on a retry (from the current time, say), or the protection does nothing:
headers: {
"Authorization": `Bearer ${process.env.ZUKKO_KEY}`,
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
"Idempotency-Key": command.trigger_id
}A repeat request with the same idempotency key within 24 hours returns the same answer without a second charge.
Different models for different Slack commands
The same bot can serve several slash commands with different needs: a short /summarize doesn't need a heavy, long-context model, while /review-code, where a large diff gets pasted into the message, benefits from the 1,000,000-token context available on the Claude and GPT models in the catalogue (GLM models have 128K). It's worth keeping a command-to-model mapping in the bot's config instead of hardcoding one model for everything — that cuts cost and speeds up replies wherever the extra depth isn't needed.
zukko
Pay 25×
less
for Claude Code & ChatGPT
One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.
Related articles
- Prompt Injection: What It Is and How to Protect a Bot and an AgentHow prompt injection works through user input and third-party documents, and which measures limit the damage: data isolation, tool permissions, key limits.
- Your Own Neural Network Proxy on Node.js: Keep the API Key Off the FrontendA small Express server between your frontend and the model API: the key stays on the server, and models, request size and rate are capped.
- DeepSeek API in Python: Requests, Streaming, JSON and Error HandlingReady code for DeepSeek V4.1 Flash and V4 Pro on the OpenAI SDK: a request, streaming, JSON parsing and careful error handling.
- Prompt Engineering Examples: 12 Ready-Made Templates for WorkTwelve prompt templates for everyday tasks: what each one says, why it is built that way, and what a single API call costs.