Build Slack and Linear bots in Go. You write handlers for mentions and thread replies; the runtime receives events over verified webhooks or Slack Socket Mode, dedupes redeliveries, serializes work per thread, and retries rate-limited API calls.
- Plain Go.
context.Context,net/http, small interfaces, and returned errors. It is a library, not a framework. - Scales out. Replicas share state on Redis, Postgres, or NATS JetStream, so they dedupe redeliveries and serialize work per thread.
- Handles slow work. Opt in to deferred dispatch to acknowledge the event first, then run LLM calls and other long handlers on a detached context.
A complete bot that replies to every mention:
package main
import (
"context"
"log"
"os"
"os/signal"
"github.com/coder/chat"
"github.com/coder/chat/adapters/slack"
"github.com/coder/chat/state/memory"
)
func main() {
ctx := context.Background()
slackAdapter, err := slack.New(ctx, slack.Options{
AppToken: os.Getenv("SLACK_APP_TOKEN"), // Socket Mode: no public URL needed
BotToken: os.Getenv("SLACK_BOT_TOKEN"),
})
if err != nil {
log.Fatal(err)
}
bot, err := chat.New(ctx,
chat.WithState(memory.New()), // swap for Redis, Postgres, or NATS in production
chat.WithAdapter(slackAdapter),
)
if err != nil {
log.Fatal(err)
}
bot.OnNewMention(func(ctx context.Context, ev *chat.MessageEvent) error {
_, err := ev.Thread.Post(ctx, chat.Markdown("**hello** _world_"))
return err
})
// Listen runs until Shutdown stops it.
go func() {
if err := bot.Listen(ctx, "slack"); err != nil {
log.Fatal(err)
}
}()
// On Ctrl-C, Shutdown stops acking new events, and then closes the connection.
interrupted, stop := signal.NotifyContext(ctx, os.Interrupt)
defer stop()
<-interrupted.Done()
if err := bot.Shutdown(ctx); err != nil {
log.Fatal(err)
}
}Turn on Socket Mode in your Slack app and create an app-level token with the
connections:write scope. Run the bot, mention it, and it replies in a
thread. The bot opens a websocket to Slack, so it needs no public URL. To
receive events over HTTP instead, set SigningSecret and serve
bot.Webhook("slack") at your app's Event Subscriptions request URL. The
tutorial walks through the Slack app setup in
under 30 minutes using
examples/slack-hello-world, the same bot
with both transports, server timeouts, graceful shutdown, and environment
checks.
Chat SDK Go requires Go 1.26.3 or newer. The core module contains the
runtime, the Slack and Linear adapters, and the memory state backend. Its
one dependency is github.com/coder/websocket, for Slack Socket Mode:
go get github.com/coder/chatThe durable state backends are separate modules, so applications that only use the core do not pull their dependencies:
go get github.com/coder/chat/state/redis
go get github.com/coder/chat/state/postgres
go get github.com/coder/chat/state/nats- Thread-scoped conversations.
OnNewMentionstarts a conversation;Thread.Subscribekeeps the bot in it, andOnSubscribedMessagereceives the follow-ups — tutorial. - Shared state you already run. Memory for development; Redis, Postgres, or NATS JetStream in production, all tested by one conformance suite — choose a state backend.
- Two Slack transports. Socket Mode needs no public URL; the Events API
over HTTP suits apps on a hosting service and Slack Marketplace apps. Both
use one adapter pipeline, so handlers get the same events.
Chat.Listenruns Socket Mode,Chat.Webhookserves HTTP, and a bot can use both. See receiving deliveries. - Ack-then-work dispatch. Handlers run synchronously by default; opt in
to
DispatchDeferredto acknowledge the event without waiting for the handler, with lock renewal and an admission cap. Five concurrency strategies (drop, queue, debounce, concurrent, burst) decide what happens when events overlap on one thread; debounce and burst require deferred dispatch — defer long-running work. - Slash commands and interactive components. Commands and button clicks have their own hooks; Block Kit and modals go through typed adapter access — slash commands, interactive components.
- Multi-tenant installs. Serve many Slack workspaces or Linear organizations from one deployment; you own the OAuth flow — multi-tenant installs.
- Linear agent sessions. Thoughts, actions, elicitations, plans, responses, and plain issue comments — Linear agent sessions.
- Rate-limit retries. Adapters honor
Retry-Afterwith bounded backoff and return a typedRateLimitederror when they give up — capability status. - Observability without extra dependencies.
sloglogging plus an optionalObserverfor metrics and spans — observability. - Message history on demand.
HistoryReaderfetches recent platform messages for a thread; what you store is up to you — message history.
supported adapters are production-grade: hardening tests, rate-limit
handling, multi-tenant installs, and docs. experimental adapters work and
are tested, but their API may still change.
| Adapter | Tier | Notes |
|---|---|---|
Slack (adapters/slack) |
supported |
Hardening tests cover rate-limit retry, multi-tenant installs, history read-through, and interactivity. No live end-to-end Slack test runs in CI. |
Linear (adapters/linear) |
experimental |
Fully implemented and hardened, but Linear's agent API is itself in developer preview; see the capability gaps. |
| Microsoft Teams | spike | Not usable yet. ADR 0007 is waiting on a live-tenant spike (#6). |
The docs index lists everything. Good starting points:
- New here? Your first Slack bot, zero to running in under 30 minutes.
- Doing a task? The how-to guides cover state backends, deferred dispatch, commands, interactivity, multi-tenant installs, and Linear.
- Looking something up? The reference and the GoDoc
(
go doc github.com/coder/chat). - Want the why? Architecture and design decisions.
Chat SDK Go follows Vercel Chat SDK's conversation model — adapters, normalized events, threads, subscriptions — where it fits Go. It is not a TypeScript API port: hooks hold one handler each, construction fails fast, subscriptions are explicit, and message history belongs to your application. See the concept map.
These are left out on purpose, each by a recorded decision (full list):
- Token streaming in the core. Deferred, not ruled out (ADR 0011); long generation posts one finished message.
- LLM orchestration. Prompts and model calls live in your handlers.
- A cross-platform card DSL. Native payloads go through
NativeContentPoster. - Transcript storage, RAG, and embeddings. Runtime state holds only subscriptions, dedupe marks, and locks.
- App-user auth and OAuth web flows. Your application owns these.
This section and the five after it form the repository blueprint: a map of
the code for contributors and coding agents. Each code directory has its own
README.md contract with Interface, Invariants, Side Effects, and
Verification sections. Citations use the form [file:symbol].
The root package chat is the runtime. It imports no other package from
this repository. A platform adapter in adapters/ receives a delivery over
Chat.Webhook or Chat.Listen, normalizes it to a chat.Event, and calls
the runtime's DispatchFunc [types.go:DispatchFunc]. The runtime then
works through these steps [runtime.go:Chat.dispatch, runtime.go:Chat.prelude]:
- Check. The event must carry an
ID, anAdapter, and aThreadID[runtime.go:validateEvent]. - Admit. The admission gate rejects the delivery once
Shutdownhas started. UnderDispatchDeferredit also rejects the delivery whenMaxDetacheddeliveries, orMaxDetachedPerTenantfor its tenant, are already admitted [admission.go:admissionGate.admit]. - Join duplicates. A concurrent delivery of an event ID that is already in the prelude waits for that delivery's outcome [runtime.go:Chat.beginEventAcceptance].
- Validate. The adapter validates the Thread ID before any lock is taken [runtime.go:Chat.prelude].
- Lock. Under
ConcurrencyDropandConcurrencyQueue,State.AcquireLocktakes the Thread Lock here.ConcurrencyDebounceandConcurrencyBursttake it later, andConcurrencyConcurrenttakes none [runtime.go:Chat.prelude]. - Route. Commands go to
OnCommandand interactions toOnInteraction. A message goes toOnSubscribedMessagewhen its thread is subscribed, else toOnNewMentionwhen it is a mention or a direct message [runtime.go:Chat.prelude, runtime.go:Chat.route]. - Mark. When the prelude knows the outcome (dropped on a lock conflict,
ignored, or routed),
State.MarkEventrecords the event ID forDedupeTTL. A duplicate stops here [runtime.go:Chat.prelude, runtime.go:Chat.routedWork, runtime.go:Chat.markAcceptedEvent]. - Run.
DispatchSyncruns the handler before the adapter acknowledges.DispatchDeferredstarts the handler in a goroutine on a detached context bounded byDetachTimeout, then returns to the adapter without waiting for it. The adapter's acknowledgement and the handler run at the same time, so a fast handler can act before the acknowledgement is written [runtime.go:Chat.dispatchDeferred, runtime.go:Chat.startDetachedTail].
Handlers reply through Thread.Post, which calls Adapter.PostMessage
[thread.go:Thread.Post]. The runtime keeps its coordination data in a
chat.State backend from state/ [state.go:State].
The root directory holds package chat. Exported symbols by file:
| File | Symbols |
|---|---|
runtime.go |
New, Option, WithState, WithAdapter, WithLogger, WithRuntimeOptions, RuntimeOptions, DefaultRuntimeOptions, ConcurrencyStrategy (ConcurrencyDrop, ConcurrencyQueue, ConcurrencyDebounce, ConcurrencyConcurrent, ConcurrencyBurst), LockScope (LockScopeThread, LockScopeChannel), DispatchMode (DispatchSync, DispatchDeferred), Chat (OnNewMention, OnSubscribedMessage, OnCommand, OnInteraction, Webhook, Listen, Thread, Shutdown), AdapterAs |
types.go |
Adapter, DispatchFunc, Event, Message, MessageEvent, MessageHandler, Command, CommandEvent, CommandHandler, Interaction, InteractionKind (InteractionUnknown, InteractionBlockAction), InteractionEvent, InteractionHandler, Actor, BotKind (BotUnknown, BotHuman, BotBot), ThreadID, ThreadRef, RetryMetadata, PostableMessage, MessageFormat (MessageFormatText, MessageFormatMarkdown), Text, Markdown, SentMessage, and the Optional Capabilities Listener, HistoryReader (HistoryQuery), EphemeralPoster (EphemeralOptions), NativeContentPoster (NativeContent) |
state.go |
State (IsThreadSubscribed, SubscribeThread, UnsubscribeThread, MarkEvent, AcquireLock, ExtendLock, ReleaseLock, Shutdown), LockLease |
thread.go |
Thread (ID, Subscribe, Unsubscribe, Post, PostEphemeral) |
install.go |
InstallStore (Lookup), Install, ErrInstallNotFound |
errors.go |
ErrUnsupportedCapability, ErrPreempted, ErrAdmissionRejected |
observer.go |
Observer, DispatchSpan, WithObserver, ObservationName and the Obs* constants, DispatchOutcome and the Outcome* constants, Attr with the Attr* keys and *Attr builders |
admission.go, burst.go |
No exported symbols. They hold the admission gate and burst batching. |
doc.go |
Package documentation (go doc github.com/coder/chat) |
Invariants:
Newfails without aState, without an adapter, without a logger, on a nil option or adapter, or on an empty or duplicate adapter name. It callsAdapter.Initfor each adapter in registration order. AnIniterror failsNew, which then shuts down every adapter registered so far, including the one that failed [runtime.go:New].validateRuntimeOptionsrejects these options [runtime.go:validateRuntimeOptions]:- a non-positive
DedupeTTLorThreadLockTTL; - under
ConcurrencyDebounce: a non-positiveDebounceInterval,DispatchSync, or aDetachTimeoutthat does not exceedDebounceInterval; - under
ConcurrencyConcurrent: a non-positiveMaxConcurrent; - under
ConcurrencyBurst: a non-positiveBurstWindow, a negativeMaxBurstBatch, orDispatchSync; - under
DispatchDeferred: a non-positiveDetachTimeoutorMaxDetached, or a negativeMaxDetachedPerTenant; - an unknown
ConcurrencyStrategy,LockScope, orDispatchModevalue.
- a non-positive
- The defaults are
DedupeTTL24h,ThreadLockTTL2m,ConcurrencyDrop,DispatchSync,LockScopeThread, andMaxDetached1024 [runtime.go:DefaultRuntimeOptions]. - Each hook holds one handler. A later
On*call replaces it atomically underhandlersMu[runtime.go:Chat.OnNewMention]. - An
Eventmust carry anID, anAdapter, and aThreadID[runtime.go:validateEvent], and it sets at most one ofMessage,Command, orInteraction[types.go:Event]. - A rejected delivery returns
ErrAdmissionRejectedbefore acknowledgement and is never marked, so the platform's retry is not deduped away [runtime.go:Chat.dispatch, errors.go:ErrAdmissionRejected]. - A failed prelude also leaves the event unmarked [runtime.go:Chat.prelude].
- An accepted event is acknowledged even when its handler returns an error. The runtime logs and observes the error and does not retry it [runtime.go:Chat.dispatchSync, doc.go].
- The runtime ignores messages, commands, and interactions from the adapter's own bot actor [runtime.go:isSelfActor].
- Under
DispatchDeferred, while a locked handler or burst batch runs, the runtime renews the lease everyThreadLockTTL/2. A lost lease cancels the handler withErrPreemptedas the cause [runtime.go:Chat.runLockedTail, burst.go:dispatchBurstBatch, runtime.go:Chat.startLockRefresh, errors.go:ErrPreempted].DispatchSyncdoes not renew: a synchronous handler that runs longer thanThreadLockTTLloses mutual exclusion and is not cancelled [runtime.go:Chat.dispatchSync]. - Lock waiters poll
State.AcquireLockeveryThreadLockTTL/20, clamped to 1ms–1s, becauseStatedoes not signal a release [runtime.go:Chat.pollForLock, runtime.go:Chat.queuePollInterval]. Shutdowncloses admission, cancels deferred work, waits for it to drain, stops listeners and waits for them, shuts down the adapters, and then shuts downState.ctxbounds the two waits. Ifctxends first,Shutdownstops waiting and shuts down the adapters andStatewhile deferred work or listeners can still run. A second call waits for the first, bounded by its ownctx[runtime.go:Chat.Shutdown, runtime.go:waitBounded].Thread.Postrejects an emptyTextand does not subscribe the thread [thread.go:Thread.Post, doc.go].Thread.PostEphemeralrejects an empty actor ID orText. It returnsErrUnsupportedCapabilitywhen the adapter does not implementEphemeralPoster[thread.go:Thread.PostEphemeral].Chat.WebhookandChat.Listenreturn an error for an adapter name that is not registered.Chat.ListenreturnsErrUnsupportedCapabilityfor an adapter that does not implementListener. AfterShutdownhas started,Chat.Listenreturns nil at once and does not call the adapter [runtime.go:Chat.Webhook, runtime.go:Chat.Listen].Observercalls are best-effort: the runtime recovers a panic and logs it at Warn [observer.go:safeEvent, observer.go:recoverObserver]. Default attributes never include Thread IDs, message text, or raw actor IDs [observer.go:AttrAdapter].- Under
ConcurrencyBurst, one runner serves each scope. The window starts at the first member's join and seals atBurstWindoworMaxBurstBatch[burst.go:burstScope, burst.go:joinBurstBatch].
Side effects:
- Starts goroutines for each detached tail [runtime.go:Chat.startDetachedTail], each lock refresh ticker [runtime.go:Chat.startLockRefresh], each burst scope runner [burst.go:joinBurstBatch], and each bounded wait [runtime.go:waitBounded].
- Calls
State.MarkEvent,AcquireLock,ExtendLock,ReleaseLock, andIsThreadSubscribed[runtime.go]. Lock release usescontext.WithoutCancel, so it still runs after the caller's context ends [runtime.go:Chat.releaseLock].Thread.SubscribeandThread.Unsubscribewrite subscriptions [thread.go:Thread.Subscribe]. - Calls the adapter methods
Init,Webhook,ValidateThreadID,PostMessage, andShutdown, plusListener.Listenfor adapters that implement it [runtime.go:New, runtime.go:Chat.Listen, thread.go:Thread.Post]. - Logs through
slogand calls theObserversynchronously on the dispatch path [observer.go:safeEvent, observer.go:safeDispatch].
- adapters: adapters/README.md - The Linear and
Slack platform adapters. Both verify webhook signatures, support
single-install and multi-tenant (
InstallStore) modes, implementchat.HistoryReader, and retry rate-limited calls only within the caller's deadline. Slack's low-level Web API and Socket Mode client isadapters/slack/slackapi, with test fakes inslackapitest. - state: state/README.md - The four
chat.Statebackends:memory(root module, in-process) andnats,postgres, andredis(each a separate Go module). All four run theinternal/statetestconformance suite. - internal: internal/README.md - Private helpers:
ratelimit(retry mechanics for the adapters),statetest(theStateconformance suite), andtokens(random lock lease tokens). - examples: examples/README.md - Five runnable
bots:
slack-hello-worldandlinear-agent-hello-worldwith in-memory state, and three Slack bots on NATS, Postgres, and Redis state, each a separate module withcompose.yamlandpitchfork.toml. - scripts: scripts/README.md -
clawpatch-autofix.sh, the developer tool behindmise run clawpatch:autofix. - docs: Prose documentation, not a code contract. See Documentation.
- Dependencies point inward.
adapters/*andstate/*import the rootchatpackage, and the root package imports neither [runtime.go, types.go, state.go]. Stateholds coordination data only: thread subscriptions, dedupe marks, and lock leases. The runtime never creates one silently [doc.go, runtime.go:New].- Every backend must pass
RunStateConformance. The firstMarkEventfor an ID within its TTL wins. Only the lease token's owner can extend or release a lock, and a stale token getsfalsewith no error. A cancelled context changes no state [internal/statetest/state.go:RunStateConformance]. - A Thread ID starts with the adapter name and a colon
[runtime.go:adapterNameFromThreadID]. The Slack adapter uses
slack:v1:[adapters/slack/slack.go:decodeThreadID], and the Linear adapter useslinear:v1:[adapters/linear/linear.go:decodeThreadID]. - An adapter turns
ErrAdmissionRejectedinto a retryable response: HTTP 503 from Linear [adapters/linear/linear.go:Webhook] andErrBusyfrom Slack, whichslackapi.Webhookserves as 503 [adapters/slack/slack.go:deliveryHandler, adapters/slack/slackapi/webhook.go:Webhook.ServeHTTP]. The exception is a Slack slash command: Slack does not redeliver it, so the adapter acks it with a busy reply instead [adapters/slack/slack.go:deliveryHandler]. - In multi-tenant mode, an adapter calls
InstallStore.Lookupfor each inbound delivery that it routes to an install, with no cache in the adapter. Deliveries that it ignores before that point, such as an unsupported Slack event type or a Linear body with no organization, get no lookup.ErrInstallNotFoundmakes the delivery an Ignored Event [install.go:InstallStore, install.go:ErrInstallNotFound, adapters/slack/slack.go:resolveInstall, adapters/linear/linear.go:resolveWebhookInstall]. - Adapter rate-limit retry never sleeps past the caller's deadline. When the
retry policy or the deadline check stops it, it returns a typed
RateLimitederror. A context cancelled during a backoff sleep returns the context error instead. Both adapters share the mechanics ininternal/ratelimit[internal/ratelimit/ratelimit.go:SleepCtx, adapters/slack/slackapi/retry.go:Client.doWithRetry, adapters/linear/retry.go:doJSONWithRetry]. go.workadds six separate modules to the root module:state/nats,state/postgres,state/redis, and the threeexamples/slack-*-statebots. Itsreplacedirectives point the root and the three state module paths at their local directories [go.work].
- Outbound network: the Slack Web API and Socket Mode websockets
(
adapters/slack/slackapi), and Linear GraphQL plus its OAuth token endpoint (adapters/linear). - Inbound HTTP: the application serves the
http.HandlerthatChat.Webhookreturns. Adapters verify each request's signature before they dispatch it [adapters/slack/slackapi/webhook.go:Webhook.ServeHTTP, adapters/linear/linear.go:Webhook]. - Persistent state: NATS JetStream KV buckets
<prefix>_sub,<prefix>_event, and<prefix>_lock. Postgres tableschat_runtime_subscriptions,chat_runtime_events, andchat_runtime_locks, whichpostgres.Newcreates. Redis keys<prefix>:sub:<id>,<prefix>:event:<id>, and<prefix>:lock:<key>. - Caller-owned clients:
Chat.ShutdowncallsState.Shutdown, which closes the NATS connection, pgx pool, or Redis client that the caller passed in. - Observability:
sloglogs and the optionalObserver. Adapters also emitObsAdapterCallandObsRateLimit. - Developer tooling:
scripts/clawpatch-autofix.shruns Codex with the sandbox disabled and creates git commits.
- Run:
mise run test. It tests the root module, the three backend modules, and the three state example modules. The Postgres and Redis backend tests use Testcontainers and need Docker. - Run:
go test ./...for the root module only. - Run:
mise run vetforgo vetacross every module.
Chat SDK Go is pre-1.0, and the public API may change before 1.0. See the releases for what changed. Report bugs and request features in GitHub issues.
See CONTRIBUTING.md for how to build, test, and propose changes.
Chat SDK Go is released under the MIT License.