Skip to main content
The shape: a mention starts a session and the thread becomes its conversation. When the agent asks a question, the question appears in the thread; whoever replies there answers it. The result lands in the same thread. The thread is the only state. The session’s metadata records which channel and thread it belongs to, so either side can find the other with one call and nothing is stored anywhere else. One Node process, standard library only, two routes: /slack/events for Slack and /gobare/events for Gobare’s webhooks.

Setting it up

In Slack: create an app, add the bot scopes app_mentions:read, channels:history and chat:write, subscribe to the bot events app_mention and message.channels, and point Event Subscriptions at https://your-host.example/slack/events. Install it; keep the bot token and the signing secret. In Gobare: subscribe the other route to the three events this needs.
Then run it with GOBARE_TOKEN, GOBARE_WEBHOOK_SECRET, SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET set.

The app

The parts that are about Slack

Three seconds. Slack re-sends an event it did not get a 200 for within three seconds, marked with x-slack-retry-num. Creating a session and posting a message takes longer than that, so the route verifies the signature, answers, and only then does the work. A retry must not start a second session. event_id is stable across Slack’s retries, so it is the Idempotency-Key: a retried mention replays the session the first attempt created. The replay is indistinguishable from the original, so the “On it” message is skipped on retries rather than posted twice. The bot hears itself. Its own messages arrive as message events too. bot_id and subtype filter them out; without that line the bot answers its own replies. The signature covers the timestamp in seconds. Slack signs v0:{seconds}:{body} with a v0= prefix. Gobare signs {milliseconds}.{body} with none. The two verifiers sit side by side in this file and are not interchangeable.

The parts that are about Gobare

A thread reply is either an answer or a follow-up. If the session has a pending question, the reply is sent as input.question_answer; otherwise it is a new input.message in the same session, and the agent continues with everything it already did. Free text is fine as an answer — the options the agent offered are suggestions, not a menu. The question is on the session, not the event. session.action_required says only that something is pending. The handler reads the session for the question’s text — name — and its arguments.options. See required actions. Nothing is deleted. A thread can be picked up again tomorrow. An idle session pauses on its own, but it holds a concurrency slot until deleted — at more than a handful of threads a day, sweep old ones with GET /v1/sessions?metadata=slack_channel:C… and delete what nobody has touched. See limits.

What was verified

Run against production, with a local mock of the Slack Web API recording every chat.postMessage. Slack’s events were signed with Slack’s algorithm and sent over HTTP; Gobare’s webhooks were signed with Gobare’s own signing function and sent the same way, because the app was not reachable from the internet:
  • Acknowledgement. url_verification returned the challenge; every event was answered in under 15 ms.
  • A retried mention with x-slack-retry-num: 1 left exactly one session for the thread, and “On it” was posted once.
  • The whole thread. The agent asked whether the release note was for customers or internal staff, with both options listed; the thread reply customers was delivered as the answer; the finished release note was posted to the same thread. All three messages carried the original thread_ts.
  • A free-text answer outside the offered options — “honestly neither, it’s for our investors” — completed normally, and the result was written for investors.
Not verified: a real Slack workspace, and Slack’s rate limits on chat.postMessage.

Next