Chat SDK Go is a Go-native semantic subset of Vercel Chat SDK's conversation runtime: adapters, normalized events, threads, subscriptions, state-backed dedupe, and thread-scoped replies.
This is not a TypeScript API port and not a promise of full Vercel Chat SDK feature parity. The goal is semantic compatibility where the model maps cleanly to Go, with deliberate Go-shaped differences where that makes the runtime simpler, safer, or easier to operate.
Status: the Slack-first MVP is implemented, and a narrow Linear app-actor slice is implemented for Linear agent sessions. The public surface is still early, but the core runtime, Slack adapter, Linear app-actor adapter, memory state, Redis and Postgres state modules, examples, and public contract tests are in place.
This project follows Vercel Chat SDK's conversation semantics where they fit Go, then narrows the MVP to a production-shaped Slack slice. The table below is the quick status map for readers familiar with Vercel Chat SDK:
| Vercel Chat SDK concept | Chat SDK Go status |
|---|---|
Chat runtime |
Implemented as chat.Chat |
| Platform adapters | Slack MVP and Linear app-actor MVP implemented |
| Normalized events and thread-scoped replies | Implemented |
onNewMention |
Implemented as OnNewMention |
onSubscribedMessage |
Implemented as OnSubscribedMessage |
| Thread subscriptions | Implemented with explicit Thread.Subscribe / Thread.Unsubscribe |
| Runtime state adapters | Memory, Redis, and Postgres implemented |
| Direct messages | Routed as implicit new mentions, then subscribed messages |
| Ephemeral messages | Slack native ephemeral plus explicit DM fallback |
| Thread handle reconstruction | Implemented with Chat.Thread |
| AI streaming responses | Not yet implemented |
| Slash commands | Implemented as OnCommand Command Events (Slack) |
| Interactive components (buttons, menus) | Implemented as OnInteraction block_actions (Slack) |
| Native rich content (Block Kit) | Implemented as NativeContentPoster Optional Capability (Slack) |
Modal open (views.open) |
Implemented as a Slack adapter Optional Capability |
Modal view_submission synchronous response |
Deferred (incompatible with ack-then-work) |
| Cards, JSX-style cards, native payload builders | Not yet implemented |
| Pattern handlers | Not yet implemented |
| Observability metrics/tracing | Optional Observer seam, no-op default, no OTel dependency in core |
| Message history persistence | App-owned (Thread Application State); thin live read-through via HistoryReader Optional Capability (Slack) |
| AI-message conversion helpers | Not yet implemented |
| Multiple production adapters | Not yet implemented |
| Middleware | Not yet implemented |
- Go-native API built around
context.Context,net/http, small interfaces, and explicit errors. - Slack-first vertical slice before claiming multi-platform portability.
- Required runtime state for subscriptions, dedupe, and locks.
- Memory state for tests and local development.
- Redis or Postgres state for horizontally scaled production deployments.
- Thread-oriented application code: handle a message, subscribe the thread, reply to the thread.
- Platform escape hatches without making raw platform structs the normal API.
- Vercel Chat SDK behavior as the default precedent unless it is non-idiomatic in Go or outside the MVP scope.
The core module is:
go get github.com/coder/chatRedis and Postgres state are optional and live in separate modules so applications that only use core, Slack, or memory state do not pull production state dependencies:
go get github.com/coder/chat/state/redis
go get github.com/coder/chat/state/postgresPackage layout:
github.com/coder/chat
github.com/coder/chat/adapters/slack
github.com/coder/chat/adapters/linear
github.com/coder/chat/state/memory
github.com/coder/chat/state/postgres
github.com/coder/chat/state/redis
This repository uses go.work for local development across the root module,
state modules, and example modules.
Which example should you run?
- Start with
examples/slack-hello-worldif you are new to the SDK or want a memory-backed bot with no local infrastructure. - Use
examples/linear-agent-hello-worldif you want to dogfood Linear app-actor agent sessions with memory state. - Use
examples/slack-redis-stateto try durable runtime coordination with Redis. - Use
examples/slack-postgres-stateif Postgres is already your coordination store.
The memory-backed Slack example runs without local infrastructure:
go run ./examples/slack-hello-worldThe memory-backed Linear app-actor example also runs without local infrastructure, but it requires a Linear OAuth app installed as an app actor and a public HTTPS webhook URL:
go run ./examples/linear-agent-hello-worldThe state-backed Slack examples live in separate example modules so the core module does not pull Redis or Postgres dependencies just to build the basic example:
examples/slack-redis-stateexamples/slack-postgres-state
Each state-backed example has its own compose.yaml, pitchfork.toml, and
README with the backend URL, service startup commands, and Slack setup steps.
For example:
cd examples/slack-redis-state
docker compose up -d redis
go run .You can also let Pitchfork supervise an example's local service from that example directory:
pitchfork start redisThe core handler for a minimal bot can be tiny:
bot.OnNewMention(func(ctx context.Context, ev *chat.MessageEvent) error {
_, err := ev.Thread.Post(ctx, chat.Text("hello world"))
return err
})Replying does not subscribe the thread. Call ev.Thread.Subscribe(ctx) when
you want later messages in the same thread to route to OnSubscribedMessage.
package main
import (
"context"
"log/slog"
"net/http"
"os"
"time"
"github.com/redis/go-redis/v9"
"github.com/coder/chat"
"github.com/coder/chat/adapters/slack"
chatredis "github.com/coder/chat/state/redis"
)
func main() {
ctx := context.Background()
redisState, err := chatredis.New(ctx, chatredis.Options{
Client: redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_ADDR"),
}),
})
if err != nil {
panic(err)
}
slackAdapter, err := slack.New(ctx, slack.Options{
SigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
BotToken: os.Getenv("SLACK_BOT_TOKEN"),
})
if err != nil {
panic(err)
}
bot, err := chat.New(ctx,
chat.WithState(redisState),
chat.WithAdapter(slackAdapter),
chat.WithLogger(slog.Default()),
chat.WithRuntimeOptions(chat.RuntimeOptions{
DedupeTTL: 24 * time.Hour,
ThreadLockTTL: 2 * time.Minute,
Concurrency: chat.ConcurrencyDrop,
}),
)
if err != nil {
panic(err)
}
defer func() {
if err := bot.Shutdown(context.Background()); err != nil {
slog.Error("chat shutdown failed", "error", err)
}
}()
bot.OnNewMention(func(ctx context.Context, ev *chat.MessageEvent) error {
if !userIsLinked(ev.Message.Author) {
_, err := ev.Thread.PostEphemeral(ctx, ev.Message.Author, chat.Text(
"Please link your account before I continue.",
), chat.EphemeralOptions{
FallbackToDM: true,
})
return err
}
if err := ev.Thread.Subscribe(ctx); err != nil {
return err
}
_, err := ev.Thread.Post(ctx, chat.Markdown(
"I'm listening to this thread now.",
))
return err
})
bot.OnSubscribedMessage(func(ctx context.Context, ev *chat.MessageEvent) error {
_, err := ev.Thread.Post(ctx, chat.Text("You said: "+ev.Message.Text))
return err
})
slackWebhook, err := bot.Webhook("slack")
if err != nil {
panic(err)
}
http.Handle("/webhooks/slack", slackWebhook)
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}
func userIsLinked(chat.Actor) bool {
return false
}Chat is the runtime. It owns adapter registration, runtime state, handler
registration, webhook mounting, dispatch, dedupe, locking, and shutdown.
Platform Adapter is a platform boundary. It verifies inbound webhooks,
normalizes platform payloads, renders outbound messages, and exposes
platform-specific APIs through typed adapter access. It does not own application
routing.
Event is the normalized inbound envelope. A Message is one payload type
inside an event, not the name for every inbound platform occurrence.
MessageEvent is the handler input for message routing hooks. It carries the
normalized event, thread, and message together.
Thread is the stable conversation address used for routing, subscription,
and replies. In Slack, a root channel message becomes a thread rooted at that
message timestamp, not the entire channel.
ThreadID is opaque and adapter-produced. It must include adapter identity and
enough platform tenant/routing context to avoid collisions across workspaces,
channels, and platforms. Application code may store and pass it around, but
must not build it manually.
Thread Handle reconstruction is supported for out-of-webhook work:
thread, err := bot.Thread(ctx, threadID)
if err != nil {
return err
}
_, err = thread.Post(ctx, chat.Text("Reminder"))The runtime decodes the adapter prefix, asks the adapter to validate the thread ID, and returns an error for unknown adapters or invalid IDs.
Construction is fail-fast:
bot, err := chat.New(ctx,
chat.WithState(state),
chat.WithAdapter(slackAdapter),
)chat.New validates state, adapter registration, runtime options, and adapter
initialization before webhooks are served. This is an intentional difference
from Vercel Chat SDK, which initializes lazily on first use.
Shutdown(ctx) is idempotent. It attempts all adapter cleanup hooks before
state cleanup and returns joined errors if cleanup fails.
The runtime exposes net/http handlers and does not own the HTTP server:
handler, err := bot.Webhook("slack")
if err != nil {
return err
}
http.Handle("/webhooks/slack", handler)Webhook lookup is fallible. A misspelled adapter name is a startup/configuration error, not a production 404.
Adapters own platform handshakes. For Slack, URL verification is handled inside the Slack webhook handler and never reaches application handlers.
The MVP has two message routing hooks:
bot.OnNewMention(func(context.Context, *chat.MessageEvent) error)
bot.OnSubscribedMessage(func(context.Context, *chat.MessageEvent) error)Routing order:
- Ignore self-authored bot messages.
- Route messages in subscribed threads to
OnSubscribedMessage. - Route mentions in unsubscribed threads to
OnNewMention. - A valid but unsupported or irrelevant platform event is acknowledged and ignored.
Direct messages are treated as implicit mentions. An unsubscribed direct message
routes to OnNewMention; once subscribed, later direct messages route to
OnSubscribedMessage.
Handlers are single-slot per hook. Calling OnNewMention or
OnSubscribedMessage again atomically replaces the previous handler. Missing
handlers are no-ops. This intentionally differs from Vercel Chat SDK, which
allows multiple handlers per hook.
Subscriptions are explicit:
if err := ev.Thread.Subscribe(ctx); err != nil {
return err
}Replying successfully to a new mention does not subscribe the thread. A subscription lasts until explicit unsubscribe.
A slash command and a button click are Events, not Messages. They ride the same dispatch spine (dedupe by Event Identity, Thread Lock, self-filtering, lock-conflict acknowledge-and-drop) but route to their own single-slot hooks:
OnCommand(func(ctx, *chat.CommandEvent) error)for Command Events (Slack slash commands). Command-ness takes precedence over subscription state: a command in a subscribed thread still routes toOnCommand, never toOnSubscribedMessage. A command does not auto-subscribe its thread.OnInteraction(func(ctx, *chat.InteractionEvent) error)for Interaction Events. This slice handles Slackblock_actions(button clicks, menu selections).
Both hooks are single-slot and no-op-when-unset, like the message hooks; an unset
handler is still acknowledged. The platform ack is adapter-owned: the Slack adapter
returns an empty 2xx within Slack's 3-second budget and preserves response_url /
trigger_id on the Raw Platform Escape Hatch. Long command/interaction work uses
the same DispatchDeferred ack-then-work primitive as messages (ADR 0002); bots
expecting commands or clicks mid-conversation should select the queue
Concurrency Strategy.
Native command/interaction responses and Block Kit content are NOT added to Postable Message, which stays Plain Text + Portable Markdown. They are reached deliberately through typed Adapter Access:
chat.NativeContentPoster.PostNativeposts opaque Block Kit blocks. ANativeContentwhose adapter does not match the target is an error, never a silent portable downgrade.- The Slack adapter's
OpenModalopens a modal viaviews.openusing a preservedtrigger_id. The synchronous modalview_submissionresponse is deferred because it is incompatible with ack-then-work. - The Slack adapter's
RespondURLposts to a preservedresponse_url.
Runtime Observation defaults to structured slog, unchanged. An optional
WithObserver(Observer) seam adds counter-style point events (dedupe hit, lock
conflict, ignored-event-by-reason, handler error, lock-release failure, adapter
call, rate limit) and a per-dispatch span with a terminal outcome
(handled, ignored, dropped-lock-conflict, duplicate, error). The default
is a no-op Observer, so an unconfigured runtime behaves exactly as before. The core
imports no OpenTelemetry, Prometheus, or statsd; attribute keys are a closed,
low-cardinality set (adapter, route, reason, outcome, tenant) and never
carry Thread ID, message text, or raw actor IDs. Observer calls are panic-safe: a
broken Observer can never fail an Accepted Event or alter acknowledgement. Under
deferred dispatch the span follows the Detached Work Context so ack-then-work
latency is measured to handler completion.
MVP dispatch is synchronous and uses the inbound webhook request context. Long-running work should be explicitly detached or queued by application code.
Once a webhook is verified and normalized into an accepted event, handler errors are recorded but acknowledged to the platform by default. This avoids platform retry storms after partial side effects such as posting a message.
Invalid signatures and malformed requests are rejected. Valid but unsupported platform events are acknowledged and ignored.
State is required. The runtime must not silently create memory state for production-facing construction.
Runtime state is coordination state:
- subscribed thread membership
- event dedupe
- thread locks
- runtime cache needed by adapters
Runtime state is not product state. Store application workflow data in your own
database keyed by ThreadID.
State implementations:
state/memory: tests and local development, included in the root modulestate/postgres: production and horizontally scaled deployments, kept in the separategithub.com/coder/chat/state/postgresmodulestate/redis: production and horizontally scaled deployments, kept in the separategithub.com/coder/chat/state/redismodule
Event dedupe uses Event Identity, not delivery retry metadata. Slack retry
headers are logged as retry metadata but are not part of the dedupe key.
Default runtime options:
chat.RuntimeOptions{
DedupeTTL: 24 * time.Hour,
ThreadLockTTL: 2 * time.Minute,
Concurrency: chat.ConcurrencyDrop,
}The MVP implements only ConcurrencyDrop. Queue, debounce, force, and
concurrent strategies are future-compatible names, not MVP behavior.
Thread locks use token-owned lock leases. Release and extend operations must verify the token so an expired handler cannot release or extend another handler's newer lock.
Lock conflict behavior defaults to acknowledge-and-drop. A lock conflict is observed as unhandled runtime contention and should not trigger platform retry.
The MVP outbound surface is intentionally small:
ev.Thread.Post(ctx, chat.Text("plain text"))
ev.Thread.Post(ctx, chat.Markdown("**portable** formatting intent"))Text means no formatting intent. Markdown means conservative CommonMark
formatting intent, not Slack mrkdwn, GitHub-flavored Markdown, or a
platform-native rich payload. Adapters may render, translate, or degrade it.
The Slack adapter uses Slack's markdown_text posting field for Markdown
messages rather than converting CommonMark to mrkdwn itself.
Posting returns SentMessage identity. Edit, delete, reactions, files, cards,
modals, and native rich payload builders are outside the MVP.
Ephemeral delivery is required for the Slack-first slice:
sent, err := ev.Thread.PostEphemeral(ctx, ev.Message.Author, chat.Text(
"Please link your account.",
), chat.EphemeralOptions{
FallbackToDM: true,
})An ephemeral message is not a normal thread reply and must never fall back to a public reply.
Fallback is explicit:
- If native ephemeral delivery works, the adapter sends native ephemeral output.
- If native ephemeral delivery is unavailable and
FallbackToDMis true, the adapter may deliver through a direct message thread. - If native ephemeral delivery is unavailable and
FallbackToDMis false, the operation returns no delivered message. - If fallback is requested but impossible, the operation returns an error.
Ephemeral behavior is modeled as an optional adapter capability through small Go interfaces, not string capability flags.
Message history is application-owned. The runtime owns coordination state (subscriptions, dedupe, locks), not a message store; durable transcripts, LLM context windows, summaries, and RAG corpora are Thread Application State kept in the application's own storage keyed by Thread ID.
For the common "fetch recent platform messages for this thread" case, an adapter
may implement the HistoryReader Optional Capability, reached through typed
adapter access like other capabilities:
hr, ok := chat.AdapterAs[interface{ chat.HistoryReader }](bot, "slack")
if ok {
msgs, err := hr.ReadHistory(ctx, ev.Thread.ID(), chat.HistoryQuery{Limit: 20})
// The app decides what, if anything, to persist as Thread Application State.
}HistoryReader is a thin live read-through, not history persistence:
ReadHistoryreads the platform API directly, keyed by the opaque Thread ID. It performs no runtime storage: no Runtime State writes, no dedupe, no caching.- It is reached only through
chat.AdapterAs; there is nobot.ReadHistoryand noThread.History, and history is never a routing hook input or auto-fetched during dispatch. - Absence of the capability is the explicit unsupported result (
ok == false), never an empty slice that masquerades as "no history". - Ordering, pagination, and page-size clamping are adapter-owned and documented in
each adapter's GoDoc. The Slack adapter returns messages newest-first, pages
toward older messages via a
Beforecursor that is aMessage.ID, and clamps the limit to Slack's maximum. - Long fetches run after ack via the ack-then-work seam; the runtime never fetches history on the inbound request path.
This deliberately diverges from Vercel Chat SDK's end-to-end stored-history model: persistence of conversation content stays an application concern.
Actor is scoped by adapter and platform tenant. Raw Slack user IDs are not
global identities.
Bot-ness is explicit:
type BotKind int
const (
BotUnknown BotKind = iota
BotHuman
BotBot
)Self-authored bot messages are ignored before subscription or mention routing.
Application identity is not part of the runtime. Account linking, login prompts, pending auth flows, and product user records belong to the application.
Normalized APIs should cover common flows. Platform-specific APIs are still reachable through typed adapter access:
slackAdapter, ok := chat.AdapterAs[*slack.Adapter](bot, "slack")
if !ok {
return errors.New("slack adapter is not registered")
}Examples should prefer this helper over unchecked type assertions.
The Slack adapter is the first production-shaped adapter. The MVP implementation covers:
- single-install configuration
- signing secret verification
- URL verification
- bot identity discovery during adapter initialization
- supported-shape decoding with unknown-field tolerance
- message-created normalization
- direct-message normalization
- root-message thread rooting
- self-message filtering
- retry metadata observation
- thread replies
- plain text and portable markdown posting, using Slack's
markdown_textfield for Markdown messages - native ephemeral messages
- explicit ephemeral DM fallback
The adapter should use local structs for the Slack payload shapes it supports, preserve raw payload data as an escape hatch, and validate required fields for supported event types.
This is a runtime and adapter MVP, not a complete Slack product surface. The goal is to prove the conversation model, state coordination, and posting contract before adding Slack-specific product features.
The Linear adapter is a narrow app-actor slice, not a full Linear adapter. The MVP implementation covers:
- single-install app-actor client credentials with granted-scope verification
- webhook signing secret verification and timestamp replay checks
- app actor and organization identity discovery during adapter initialization
- Linear
AgentSessionEventcreated and prompted normalization, including assignment/delegation-created sessions emitted by Linear - source-comment-based event identity for dedupe
- tenant-correct opaque Linear agent session thread IDs
- runtime self-message filtering through the discovered app actor identity
- thread handle reconstruction for stored Linear agent session thread IDs
- final responses as Linear agent activity responses
- ephemeral thoughts through typed adapter access with
PostThought - plain text and portable markdown pass-through for Linear activity bodies
- one memory-backed hello-world example with setup and dogfooding instructions
The Linear adapter follows the Slack adapter pattern: supported payload shapes are modeled locally, low-level HTTP/GraphQL calls stay private, and public platform-specific behavior is exposed through narrow methods rather than a raw Linear client.
For the tracked list of Linear agent APIs and best-practice behaviors that are
not yet implemented, see docs/linear-agent-capabilities.md.
These are not bugs in the MVP:
- no TypeScript API compatibility
- no full Vercel Chat SDK feature parity
- no multiple handlers per routing hook
- no lazy runtime initialization
- no full Linear adapter beyond the app-actor agent-session slice
- no Linear personal API key, static access-token, generic comments mode, or multi-tenant OAuth installation flow
- no Linear streaming, plans, actions, reactions, history, or Markdown conversion
- no multi-workspace Slack OAuth installation flow
- no live Slack end-to-end test in CI
- no Slack Web API rate-limit retry/backoff policy
- no dedicated
OnDirectMessagehook - no public proactive
OpenDM, except adapter behavior needed for explicit ephemeral fallback - no pattern handlers
- no middleware
- no message history APIs
- no thread application state APIs
- no JSX cards, files, or typed Block Kit / Adaptive Card payload builders
(native Block Kit content ships as an opaque payload via
NativeContentPoster) - no Slack shortcuts or Block Kit workflow steps (block_actions buttons and menus are routed as Interaction Events)
- no synchronous modal
view_submissionresponse (modal-open viaviews.openships; the synchronousresponse_actionis incompatible with ack-then-work and is deferred) - no edit, delete, reaction, or other outbound mutation APIs beyond what a native interaction response needs
- no bundled metrics framework, exporters, or scrape endpoint (an optional no-op
Observerseam is provided; OpenTelemetry stays out of the core import graph) - no queue, debounce, force, or concurrent lock-conflict strategies
- no built-in HTTP server or router integrations
- no adapter marketplace/package conventions
Tests should verify external behavior and public contracts, not private implementation details.
Required test families:
- runtime construction and shutdown
- handler registration and replacement
- routing order and no-op missing handlers
- explicit subscription and unsubscribe
- direct-message implicit mention routing
- self-message filtering
- accepted, ignored, rejected, duplicate, and lock-conflict events
- state conformance across memory, Redis, and Postgres
- token-owned lock lease acquire, release, extend, expiry, and stale release
- Slack signature verification and URL verification
- Slack golden payload normalization
- thread ID construction and validation
- thread handle reconstruction
- text, markdown, sent message, ephemeral, and ephemeral fallback posting
- typed adapter access
- README and GoDoc coverage for intentional Vercel differences
Local test commands:
mise run test
mise run test:root
mise run test:adapters
mise run test:examples
mise run test:postgres
mise run test:redismise run test is a composite task that runs the root module tests,
test:adapters, and test:examples. The adapter-focused task also exercises
the Redis and Postgres state modules. The Redis and Postgres state tests use
Testcontainers for real backend coverage and skip when Docker is unavailable.