diff --git a/AGENTS.md b/AGENTS.md index 63fee9b..9ac1134 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ### Issue tracker -Issues and PRDs are tracked as local markdown files under `.scratch/`. See `docs/agents/issue-tracker.md`. +GitHub issues (https://github.com/coder/chat/issues) are the public source of truth for roadmap and bugs; `.scratch/` holds internal working notes only. See `docs/agents/issue-tracker.md`. ### Triage labels diff --git a/CONTEXT.md b/CONTEXT.md index bc9f078..ef3846c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -417,7 +417,7 @@ _Avoid_: Full platform schema, strict external SDK model - **Runtime Options** TTL values must be positive. - **Runtime Options** include a **Concurrency Strategy** that defaults to drop. - The runtime implements the drop (default) and queue **Concurrency Strategy** values; burst, debounce, concurrent, lock-scope, and force/steerability remain reserved for future slices. -- A **Thread Lock** must not drop distinct **Webhook Events** for the same **Thread**; it only coordinates their processing. +- A **Thread Lock** coordinates processing of distinct **Webhook Events** for the same **Thread**; it never deduplicates them, and what happens to a conflicting event is decided by the **Concurrency Strategy** (drop acknowledges and drops it; queue coalesces waiters per process and runs the most recent after the lock releases). - A **Thread Lock** is represented as a **Lock Lease** with an ownership token. - Releasing or extending a **Lock Lease** must verify the ownership token so an expired holder cannot affect a newer holder. - A **Lock Conflict** is acknowledged to the platform by default and recorded as unhandled runtime contention. diff --git a/README.md b/README.md index 27ab1a3..1f35922 100644 --- a/README.md +++ b/README.md @@ -11,31 +11,64 @@ 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. +Status: the core runtime, the Slack adapter, the Linear adapter (agent +sessions and generic issue comments), four state backends (memory, Redis, +Postgres, NATS JetStream), runnable examples, and public contract tests are in +place. The public Go API surface is still early and may change. + +## Adapter Maturity + +Adapters are tiered honestly: + +- **`supported`** — production-grade: hardening test suites, rate-limit + handling, multi-tenant installs, and documentation. A reasonable default + choice for production. +- **`experimental`** — implemented and tested, but no promises: the platform + surface, the adapter API, or both may still change. + +| Adapter | Tier | Notes | +| --- | --- | --- | +| Slack (`adapters/slack`) | `supported` | Hardening tests for rate-limit retry ([ADR 0005](docs/adr/0005-rate-limit-handling.md)), multi-tenant installs ([ADR 0006](docs/adr/0006-multi-tenant-install.md)), history read-through ([ADR 0009](docs/adr/0009-message-history.md)), and interactivity. No live end-to-end Slack test runs in CI. | +| Linear (`adapters/linear`) | `experimental` | Fully implemented and hardened (agent sessions, generic comments, rate-limit retry, multi-tenant, history read-through), but the upstream Linear agent API is itself in developer preview and [capability gaps remain](docs/linear-agent-capabilities.md) (some operations are GraphQL-escape-hatch only). | +| Microsoft Teams | spike | [ADR 0007](docs/adr/0007-teams-adapter.md) is a proposal gated on a live-tenant spike (draft [PR #4](https://github.com/coder/chat/pull/4), tracked in [#6](https://github.com/coder/chat/issues/6)). Not usable yet. | + +## Documentation + +Documentation follows [Diátaxis](https://diataxis.fr/). The +[docs index](docs/README.md) maps it all; the short version: + +- **Tutorial**: [your first Slack bot](docs/tutorials/slack-bot.md) — zero to + a running bot in under 30 minutes. +- **How-to guides**: [state backends](docs/how-to/choose-a-state-backend.md), + [deferred dispatch](docs/how-to/deferred-dispatch.md), + [slash commands](docs/how-to/slash-commands.md), + [interactive components](docs/how-to/interactive-components.md), + [multi-tenant installs](docs/how-to/multi-tenant-install.md), and + [Linear agent sessions](docs/how-to/linear-agent-sessions.md). +- **Reference**: [package and API reference](docs/reference.md) (pkg.go.dev + pointers and per-adapter capability status). +- **Explanation**: [architecture and design decisions](docs/explanation.md) + — an index over [`CONTEXT.md`](CONTEXT.md) and the [ADRs](docs/adr/). ## Vercel Chat SDK Alignment -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 +This project follows Vercel Chat SDK's conversation semantics where they fit +Go, built outward from 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 | +| Platform adapters | Slack (supported) and Linear (experimental) implemented; Teams is a spike | | 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 | +| Runtime state adapters | Memory, Redis, Postgres, and NATS JetStream 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 | +| AI streaming responses | Deferred from core, not foreclosed (ADR 0011); long generation uses ack-then-work | | 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) | @@ -46,7 +79,7 @@ quick status map for readers familiar with Vercel Chat SDK: | 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, Linear) | | AI-message conversion helpers | Not yet implemented | -| Multiple production adapters | Not yet implemented | +| Multiple production adapters | Slack is the only `supported` adapter; Linear is `experimental` | | Middleware | Not yet implemented | ## Design Goals @@ -56,12 +89,13 @@ quick status map for readers familiar with Vercel Chat SDK: - 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. +- Redis, Postgres, or NATS JetStream 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. + in Go or outside the documented scope. ## Install @@ -71,13 +105,14 @@ The core module is: go get github.com/coder/chat ``` -Redis and Postgres state are optional and live in separate modules so +Redis, Postgres, and NATS state are optional and live in separate modules so applications that only use core, Slack, or memory state do not pull production state dependencies: ```sh go get github.com/coder/chat/state/redis go get github.com/coder/chat/state/postgres +go get github.com/coder/chat/state/nats ``` Package layout: @@ -87,6 +122,7 @@ 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/nats github.com/coder/chat/state/postgres github.com/coder/chat/state/redis ``` @@ -99,13 +135,15 @@ state modules, and example modules. Which example should you run? - Start with `examples/slack-hello-world` if you are new to the SDK or want a - memory-backed bot with no local infrastructure. + memory-backed bot with no local infrastructure. The + [tutorial](docs/tutorials/slack-bot.md) walks through it end to end. - Use `examples/linear-agent-hello-world` if you want to dogfood Linear app-actor agent sessions with memory state. - Use `examples/slack-redis-state` to try durable runtime coordination with Redis. - Use `examples/slack-postgres-state` if Postgres is already your coordination store. +- Use `examples/slack-nats-state` if you already run NATS with JetStream. The memory-backed Slack example runs without local infrastructure: @@ -122,11 +160,12 @@ go run ./examples/linear-agent-hello-world ``` The 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: +module does not pull Redis, Postgres, or NATS dependencies just to build the +basic example: - `examples/slack-redis-state` - `examples/slack-postgres-state` +- `examples/slack-nats-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. @@ -336,7 +375,7 @@ the Slack webhook handler and never reaches application handlers. ## Routing -The MVP has two message routing hooks: +The runtime has two message routing hooks: ```go bot.OnNewMention(func(context.Context, *chat.MessageEvent) error) @@ -386,11 +425,12 @@ lock-conflict acknowledge-and-drop) but route to their own single-slot hooks: 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. +returns an empty 2xx and preserves `response_url` / `trigger_id` on the `Raw` +Platform Escape Hatch. Under the default synchronous dispatch the handler runs +before that ack, so long command/interaction work should use the same +`DispatchDeferred` ack-then-work primitive as messages (ADR 0002) to stay inside +Slack's 3-second budget; 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 @@ -399,9 +439,10 @@ deliberately through typed Adapter Access: - `chat.NativeContentPoster.PostNative` posts opaque Block Kit blocks. A `NativeContent` whose adapter does not match the target is an error, never a silent portable downgrade. -- The Slack adapter's `OpenModal` opens a modal via `views.open` using a preserved - `trigger_id`. The synchronous modal `view_submission` response is deferred - because it is incompatible with ack-then-work. +- The Slack adapter's `OpenModalFromRaw` (and `OpenModal` for callers holding a + `trigger_id`) opens a modal via `views.open` using the `trigger_id` preserved + on the `Raw` escape hatch. The synchronous modal `view_submission` response is + deferred because it is incompatible with ack-then-work. - The Slack adapter's `RespondURL` posts to a preserved `response_url`. ### Observability @@ -421,8 +462,12 @@ latency is measured to handler completion. ## Dispatch And Acknowledgement -MVP dispatch is synchronous and uses the inbound webhook request context. -Long-running work should be explicitly detached or queued by application code. +The default dispatch mode is synchronous (`DispatchSync`): handlers run on the +inbound webhook request context before the platform acknowledgement. For +long-running work, opt in to `DispatchDeferred` (ack-then-work, ADR 0002): the +dedupe/lock prelude runs before the ack, then the handler runs on a detached +work context with automatic lock lease renewal. See the +[deferred dispatch guide](docs/how-to/deferred-dispatch.md). 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 @@ -453,6 +498,10 @@ State implementations: separate `github.com/coder/chat/state/postgres` module - `state/redis`: production and horizontally scaled deployments, kept in the separate `github.com/coder/chat/state/redis` module +- `state/nats`: production deployments that already run NATS with JetStream, + kept in the separate `github.com/coder/chat/state/nats` module + +The [state backend guide](docs/how-to/choose-a-state-backend.md) compares them. ## Dedupe, Locks, And Concurrency @@ -469,8 +518,11 @@ chat.RuntimeOptions{ } ``` -The MVP implements only `ConcurrencyDrop`. Queue, debounce, force, and -concurrent strategies are future-compatible names, not MVP behavior. +Two concurrency strategies are implemented: `ConcurrencyDrop` (the default) +acknowledges and drops events that hit a locked thread, and `ConcurrencyQueue` +waits for the lock and runs only the most recent superseded follow-up, with +most-recent coalescing scoped per process (ADR 0012). Burst, debounce, force, +and concurrent strategies remain proposed in ADR 0012 and are not implemented. Thread locks use token-owned lock leases. Release and extend operations must verify the token so an expired handler cannot release or extend another @@ -481,7 +533,7 @@ observed as unhandled runtime contention and should not trigger platform retry. ## Messages -The MVP outbound surface is intentionally small: +The portable outbound surface is intentionally small: ```go ev.Thread.Post(ctx, chat.Text("plain text")) @@ -494,8 +546,10 @@ 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. +Posting returns `SentMessage` identity. Edit, delete, reactions, files, and +typed rich payload builders are outside the portable surface. Platform-native +content and Slack modal opening are reachable deliberately through typed +adapter access (see [Command And Interaction Events](#command-and-interaction-events)). ## Ephemeral Messages @@ -601,12 +655,12 @@ if !ok { Examples should prefer this helper over unchecked type assertions. -## Slack MVP Status +## Slack Adapter Status -The Slack adapter is the first production-shaped adapter. The MVP -implementation covers: +The Slack adapter is the first `supported` adapter. The implementation covers: - single-install configuration +- multi-tenant installs via an application-implemented `InstallStore` (ADR 0006) - signing secret verification - URL verification - bot identity discovery during adapter initialization @@ -621,31 +675,50 @@ implementation covers: for Markdown messages - native ephemeral messages - explicit ephemeral DM fallback +- slash commands as Command Events and `block_actions` as Interaction Events + (ADR 0003, ADR 0004) +- native Block Kit posting, modal open, and `response_url` responses via typed + adapter access +- Web API rate-limit retry with `Retry-After` handling, bounded backoff, and a + typed `RateLimited` error (ADR 0005) +- thread history read-through via the `HistoryReader` Optional Capability + (ADR 0009) -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. +The adapter uses local structs for the Slack payload shapes it supports, +preserves raw payload data as an escape hatch, and validates 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. +This is still not a complete Slack product surface: see +[Intentional Gaps](#intentional-gaps) for what is deliberately absent. -## Linear App-Actor MVP Status +## Linear Adapter Status -The Linear adapter is a narrow app-actor slice, not a full Linear adapter. The -MVP implementation covers: +The Linear adapter is `experimental`: the implementation is broad and +hardened, but the upstream Linear agent API is itself in developer preview, +so no production promises are made yet. The implementation covers: - single-install app-actor client credentials with granted-scope verification +- multi-tenant installs via an application-implemented `InstallStore`, with + per-install webhook secrets and credentials or pre-exchanged access tokens + (ADR 0006) - webhook signing secret verification and timestamp replay checks - app actor and organization identity discovery during adapter initialization - Linear `AgentSessionEvent` created and prompted normalization, including assignment/delegation-created sessions emitted by Linear +- generic issue/comment participation outside agent sessions, with a + thread-kind discriminator in the opaque thread ID (ADR 0013) - source-comment-based event identity for dedupe -- tenant-correct opaque Linear agent session thread IDs +- tenant-correct opaque Linear 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` +- thread handle reconstruction for stored Linear thread IDs +- the full agent activity surface through typed adapter access: thoughts, + responses, actions, elicitations, errors, and session updates with plans and + external URLs (ADR 0008) +- thread history read-through via the `HistoryReader` Optional Capability, + reading agent-session activities and issue-comment threads (ADR 0009) +- GraphQL rate-limit retry with a typed `RateLimited` error (ADR 0005) +- a `GraphQL` escape hatch and a `RawMessage` escape hatch (including the + user-initiated stop signal) - plain text and portable markdown pass-through for Linear activity bodies - one memory-backed hello-world example with setup and dogfooding instructions @@ -655,29 +728,61 @@ 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`. - -## Intentional MVP Gaps - -These are not bugs in the MVP: +not yet implemented, see +[`docs/linear-agent-capabilities.md`](docs/linear-agent-capabilities.md). + +## Non-Goals + +These are deliberate design boundaries, each recorded in an ADR. Most are +permanent ownership boundaries; streaming is the one explicitly *deferred* +boundary — out of the core runtime today, not foreclosed forever: + +- **Streaming token transport in the core runtime** — [ADR 0011](docs/adr/0011-resumable-streaming.md) + defers token streaming and pub/sub transports out of core (without + foreclosing a future optional capability); long generation is ack-then-work + ([ADR 0002](docs/adr/0002-async-dispatch.md)) posting one finished message. +- **LLM routing and prompt orchestration** — the runtime coordinates + conversations; LLM calls, prompt assembly, and generation pipelines are + application concerns inside handlers ([ADR 0011](docs/adr/0011-resumable-streaming.md) + classifies generation and stream persistence as app/LLM concerns; + [`CONTEXT.md`](CONTEXT.md) defines the runtime boundary). +- **A generative-UI card DSL** — [ADR 0004](docs/adr/0004-interactive-components.md) + rejected a cross-platform card model as lossy; platform-native payloads ship + opaquely via `NativeContentPoster` instead. +- **RAG and embeddings** — [ADR 0009](docs/adr/0009-message-history.md) keeps + embeddings, summaries, and RAG corpora as Thread Application State in the + application's own database keyed by Thread ID. +- **Durable transcript persistence in `chat.State`** — [ADR 0009](docs/adr/0009-message-history.md) + rejected baking a message store into runtime state; `chat.State` stays + subscriptions, dedupe, and locks. +- **App-user auth orchestration** — [ADR 0006](docs/adr/0006-multi-tenant-install.md) + scopes the install store to platform-tenant credentials; account linking, + login prompts, and OAuth web flows are Application Identity and stay + app-owned. + +## Intentional Gaps + +These are not bugs; they are things the current scope deliberately does not +include: - 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 Linear personal API key mode, and no single-install static access token + (pre-exchanged access tokens are supported through the multi-tenant + `InstallStore`) +- no Linear streaming, reactions, or Markdown conversion +- no built-in OAuth web flow: authorize/callback/token-exchange routes and + install storage are application-owned (ADR 0006) - no live Slack end-to-end test in CI -- no Slack Web API rate-limit retry/backoff policy - no dedicated `OnDirectMessage` hook - no public proactive `OpenDM`, except adapter behavior needed for explicit ephemeral fallback - no pattern handlers - no middleware -- no message history APIs +- no history persistence APIs: `HistoryReader` is a storage-free live + read-through, implemented by the Slack and Linear adapters - 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`) @@ -690,7 +795,8 @@ These are not bugs in the MVP: interaction response needs - no bundled metrics framework, exporters, or scrape endpoint (an optional no-op `Observer` seam is provided; OpenTelemetry stays out of the core import graph) -- no queue, debounce, force, or concurrent lock-conflict strategies +- no burst, debounce, force, or concurrent lock-conflict strategies (drop and + queue are implemented) - no built-in HTTP server or router integrations - no adapter marketplace/package conventions @@ -708,7 +814,7 @@ Required test families: - direct-message implicit mention routing - self-message filtering - accepted, ignored, rejected, duplicate, and lock-conflict events -- state conformance across memory, Redis, and Postgres +- state conformance across memory, Redis, Postgres, and NATS - token-owned lock lease acquire, release, extend, expiry, and stale release - Slack signature verification and URL verification - Slack golden payload normalization @@ -725,11 +831,13 @@ mise run test mise run test:root mise run test:adapters mise run test:examples +mise run test:nats mise run test:postgres mise run test:redis ``` `mise 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. +the NATS, Redis, and Postgres state modules. The Redis and Postgres state +tests use Testcontainers for real backend coverage and skip when Docker is +unavailable; the NATS tests run against an embedded JetStream server. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..bc28422 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,47 @@ +# Chat SDK Go Documentation + +User-facing documentation is organized along [Diátaxis](https://diataxis.fr/): +learning-oriented tutorials, task-oriented how-to guides, information-oriented +reference, and understanding-oriented explanation. + +## Tutorials + +Start here if you are new to the SDK. + +- [Your first Slack bot](tutorials/slack-bot.md) — zero to a running Slack bot + in under 30 minutes. + +## How-To Guides + +Task-oriented guides for people already running a bot. + +- [Choose a state backend](how-to/choose-a-state-backend.md) — memory, Redis, + Postgres, or NATS JetStream. +- [Defer long-running work (ack-then-work)](how-to/deferred-dispatch.md) — + acknowledge webhooks fast and run handlers on a detached context. +- [Handle slash commands](how-to/slash-commands.md) — route Slack slash + commands through `OnCommand`. +- [Handle interactive components](how-to/interactive-components.md) — buttons, + menus, Block Kit content, and modals. +- [Install into multiple workspaces (multi-tenant)](how-to/multi-tenant-install.md) — + resolve per-tenant credentials with an `InstallStore`. +- [Run Linear agent sessions](how-to/linear-agent-sessions.md) — build a Linear + agent with thoughts, responses, actions, elicitations, and plans. + +## Reference + +- [API and package reference](reference.md) — pkg.go.dev pointers, module + layout, and per-adapter capability status. +- [Linear agent capability gaps](linear-agent-capabilities.md) — tracked list + of Linear agent APIs the adapter does not yet wrap. + +## Explanation + +- [Architecture and design decisions](explanation.md) — an index over + [`CONTEXT.md`](../CONTEXT.md) (the ubiquitous language and architecture + document) and the [ADRs](adr/) that record every significant decision. + +## Non-User Documentation + +- [`docs/agents/`](agents/) — instructions for coding agents working on this + repository, not for SDK users. diff --git a/docs/adr/0006-multi-tenant-install.md b/docs/adr/0006-multi-tenant-install.md index 2b83336..20a01ff 100644 --- a/docs/adr/0006-multi-tenant-install.md +++ b/docs/adr/0006-multi-tenant-install.md @@ -50,6 +50,8 @@ Specifically: - **Lookup ordering.** During webhook handling the adapter: (1) parses the **Platform Tenant** out of the **Supported Platform Shape**; (2) calls `InstallStore.Lookup`; (3) verifies the signature using install-record material where the platform signs per-install, or the shared app-level signing secret where it does not (Slack); (4) normalizes the tenant-scoped **Event** and hands it to **Runtime Dispatch**. On platforms with a per-install signing secret the tenant must be read from an unverified body for routing only, then re-validated by signature verification before any side effect. + *Implementation note (as built):* the Slack adapter verifies the shared app-level signature **before** parsing the tenant or calling `Lookup`, so a Slack `InstallStore` only ever sees tenants from verified requests. The unverified-routing-read ordering above applies to per-install-signed platforms (Linear), where the store's tenant argument is untrusted routing input by necessity. + - **Not-installed is an Ignored Event.** `ErrInstallNotFound` is acknowledged to the platform without dispatch, consistent with CONTEXT.md's **Ignored Event** definition. Any other **Install Store** error is a transport failure the platform may retry. - **Out-of-webhook posting uses the same resolver.** **Thread Handle** reconstruction decodes the **Platform Tenant** from the **Thread ID**, calls `InstallStore.Lookup`, and posts. A stored **Thread ID** stays postable while the app holds a valid install record. @@ -71,7 +73,7 @@ Deliberate divergences from the upstream Chat SDK and trade-offs: - The runtime owns the credential-lookup *contract* but never the store, unlike full marketplace SDKs that ship installation persistence. - The OAuth web flow is explicitly out: the app owns authorize/callback routes, matching how the runtime exposes only **Webhook Handlers** and owns no HTTP server. -- On per-app-signed platforms (Slack) the tenant is parsed from an unverified body before verification. This is a routing read only, re-validated by signature verification before side effects; adapters document the ordering. +- On per-install-signed platforms (Linear) the tenant is parsed from an unverified body before verification. This is a routing read only, re-validated by signature verification before side effects; adapters document the ordering. (On per-app-signed platforms — Slack — the implementation verifies the shared signature first, so lookup happens post-verification.) - The credential payload is adapter-specific (`any`), trading a normalized token model for honesty about how differently Slack and Linear authorize. Costs: the app must build and secure the **Install Store**; a misimplemented store (returning a stale or wrong-tenant credential) can post to the wrong workspace, so tenant-correctness tests are required. Adapters carry two construction modes to keep tested. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index a2f08fb..1af42e5 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -1,19 +1,35 @@ -# Issue tracker: Local Markdown +# Issue tracker: GitHub Issues -Issues and PRDs for this repo live as markdown files in `.scratch/`. +The public source of truth for this repo's roadmap, feature requests, and bug +reports is GitHub issues: -## Conventions +https://github.com/coder/chat/issues + +Anything user-facing — planned work, accepted/rejected proposals, bug state — +belongs there. Use `gh issue` to read and update it. + +## `.scratch/` is internal working notes only + +The `.scratch/` directory holds internal working notes: PRDs, implementation +breakdowns, and drafts that agents produce while working. It is not a public +roadmap, it makes no promises, and nothing in it should be treated as +authoritative over GitHub issues. When a scratch note graduates into real +planned work, file a GitHub issue for it. + +### Conventions for `.scratch/` - One feature per directory: `.scratch//` - The PRD is `.scratch//PRD.md` -- Implementation issues are `.scratch//issues/-.md`, numbered from `01` -- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings) +- Implementation notes are `.scratch//issues/-.md`, numbered from `01` +- Triage state is recorded as a `Status:` line near the top of each file (see `triage-labels.md` for the role strings) - Comments and conversation history append to the bottom of the file under a `## Comments` heading ## When a skill says "publish to the issue tracker" -Create a new file under `.scratch//` (creating the directory if needed). +Create a GitHub issue with `gh issue create`. Use `.scratch//` +only for supporting working notes that are not ready to be public. ## When a skill says "fetch the relevant ticket" -Read the file at the referenced path. The user will normally pass the path or the issue number directly. +If the reference is a number or URL, read the GitHub issue with +`gh issue view`. If the reference is a path, read the file at that path. diff --git a/docs/explanation.md b/docs/explanation.md new file mode 100644 index 0000000..7f236fc --- /dev/null +++ b/docs/explanation.md @@ -0,0 +1,71 @@ +# Architecture And Design Decisions + +Chat SDK Go's design is documented in two places, and this page is the index +over both: + +- [`CONTEXT.md`](../CONTEXT.md) — the ubiquitous language and architecture + document. It defines every domain term precisely (with the synonyms to + avoid), states the architectural invariants as explicit relationships, and + records resolved ambiguities against upstream Vercel Chat SDK behavior. +- [`docs/adr/`](adr/) — Architecture Decision Records. Every significant + decision has one, including the decisions *not* to build something. + +## Reading CONTEXT.md + +If you want to understand the system, read `CONTEXT.md` top to bottom; it is +the single most information-dense document in the repository. Its `Language` +section groups the vocabulary by area — runtime lifecycle, the platform +adapter boundary, Linear session lifecycle, threads and routing, the +event/message model, dispatch and concurrency, observability, state and +history, content and formatting, and tenancy and identity. The +`Relationships` section is the closest thing to a formal specification of the +runtime's invariants, and `Flagged ambiguities` explains where and why the +design deliberately diverges from Vercel Chat SDK. + +## Decision Records + +| ADR | Decision | Status | +| --- | --- | --- | +| [0001](adr/0001-linear-app-actor-slice.md) | Linear app-actor slice before a full Linear adapter | Accepted | +| [0002](adr/0002-async-dispatch.md) | Deferred runtime dispatch (ack-then-work) | Accepted | +| [0003](adr/0003-slash-commands.md) | Command Events and slash command routing | Accepted | +| [0004](adr/0004-interactive-components.md) | Interaction Events and native content instead of a card DSL | Accepted | +| [0005](adr/0005-rate-limit-handling.md) | Rate-limit retry lives in adapters, with typed `RateLimited` errors | Accepted | +| [0006](adr/0006-multi-tenant-install.md) | Multi-tenant installs via app-implemented `InstallStore`; OAuth flows stay app-owned | Accepted | +| [0007](adr/0007-teams-adapter.md) | Microsoft Teams adapter approach (Bot Framework, direct HTTP) | Proposed — gated on a spike | +| [0008](adr/0008-linear-full-adapter.md) | Full Linear agent activity surface (thought/response/action/elicitation/error) plus session updates (plans, external URLs) | Accepted | +| [0009](adr/0009-message-history.md) | Message history stays application-owned; optional storage-free `HistoryReader` | Accepted | +| [0010](adr/0010-observability.md) | Optional `Observer` seam; no OpenTelemetry in core | Accepted | +| [0011](adr/0011-resumable-streaming.md) | Resumable streaming deferred from core, not foreclosed | Proposed | +| [0012](adr/0012-concurrency-strategy.md) | Concurrency strategy expansion (`queue` implemented; `burst`/`debounce`/`concurrent` staged) | Accepted (staged) | +| [0013](adr/0013-linear-generic-comments.md) | Linear generic issue/comment participation | Accepted | +| [0014](adr/0014-nats-state-adapter.md) | NATS JetStream state adapter | Accepted | + +## The Short Version + +For readers who want the model in five paragraphs: + +**The runtime coordinates conversations; it does not own your product.** +`chat.Chat` verifies webhooks through adapters, normalizes platform payloads +into events, dedupes them, serializes work per thread with token-owned lock +leases, and routes to your single-slot handlers. Everything your product +stores — transcripts, user records, workflow state — lives in your database, +keyed by the opaque `ThreadID`. + +**Adapters own the platform boundary.** Signature verification, payload +normalization, outbound rendering, rate-limit retries, and platform quirks +live inside the adapter. Platform-specific power is reached deliberately via +typed adapter access (`chat.AdapterAs`), never by making raw platform structs +the normal API. + +**State is required and small.** Subscriptions, dedupe marks, and locks — +that is all. Memory for development; Redis, Postgres, or NATS for production. + +**Events are broader than messages.** A slash command and a button click are +normalized events with their own hooks, not messages. All events ride the +same dispatch spine. + +**Semantic compatibility, not feature parity.** Vercel Chat SDK's +conversation model is the precedent; its TypeScript API shapes are not. Where +Go idioms or operational safety argue otherwise, this SDK deliberately +diverges and documents the divergence. diff --git a/docs/how-to/choose-a-state-backend.md b/docs/how-to/choose-a-state-backend.md new file mode 100644 index 0000000..05c3fb6 --- /dev/null +++ b/docs/how-to/choose-a-state-backend.md @@ -0,0 +1,141 @@ +# How To Choose A State Backend + +Runtime state is required: the runtime stores subscribed-thread membership, +event dedupe marks, and thread lock leases in a `chat.State`. It is +coordination state, not product state — keep your application's own data in +your own database keyed by `ThreadID`. + +Four implementations ship today: + +| Backend | Module | Use for | +| --- | --- | --- | +| Memory | `github.com/coder/chat/state/memory` (in the core module) | Tests and local demos. Lost on restart. | +| Redis | `github.com/coder/chat/state/redis` | Production, horizontally scaled deployments. | +| Postgres | `github.com/coder/chat/state/postgres` | Production, when Postgres is already your coordination store. | +| NATS JetStream | `github.com/coder/chat/state/nats` | Production, when you already run NATS with JetStream. | + +Redis, Postgres, and NATS live in separate Go modules so applications that only +need core, Slack, or memory state do not pull their dependencies. + +## Memory + +```go +import "github.com/coder/chat/state/memory" + +bot, err := chat.New(ctx, + chat.WithState(memory.New()), + chat.WithAdapter(adapter), +) +``` + +Memory state is for tests and local development only. Subscriptions and dedupe +data vanish when the process exits, so a restarted bot forgets which threads it +was in and may re-handle redelivered events. + +## Redis + +```sh +go get github.com/coder/chat/state/redis +``` + +```go +import ( + "github.com/redis/go-redis/v9" + + chatredis "github.com/coder/chat/state/redis" +) + +redisState, err := chatredis.New(ctx, chatredis.Options{ + Client: redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_ADDR")}), + Prefix: "mybot", // see "One namespace per bot application" below +}) +``` + +The runnable example, including a `compose.yaml` for a local Redis, is +[`examples/slack-redis-state`](../../examples/slack-redis-state/README.md). + +## Postgres + +```sh +go get github.com/coder/chat/state/postgres +``` + +```go +import ( + "github.com/jackc/pgx/v5/pgxpool" + + chatpostgres "github.com/coder/chat/state/postgres" +) + +pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL")) +if err != nil { + return err +} + +pgState, err := chatpostgres.New(ctx, chatpostgres.Options{ + Pool: pool, + Namespace: "mybot", // see "One namespace per bot application" below +}) +``` + +The Postgres state initializes its own schema (subscription, event, and lock +tables) on startup. The runnable example is +[`examples/slack-postgres-state`](../../examples/slack-postgres-state/README.md). + +## NATS JetStream + +```sh +go get github.com/coder/chat/state/nats +``` + +```go +import ( + natsgo "github.com/nats-io/nats.go" + + chatnats "github.com/coder/chat/state/nats" +) + +conn, err := natsgo.Connect(os.Getenv("NATS_URL")) +if err != nil { + return err +} + +natsState, err := chatnats.New(ctx, chatnats.Options{ + Conn: conn, + Prefix: "mybot", // see "One namespace per bot application" below + // DedupeTTL and ThreadLockTTL default to the runtime defaults (24h and + // 2m) and must match your RuntimeOptions. +}) +``` + +NATS state stores subscriptions, dedupe marks, and locks in three JetStream +Key-Value buckets with bucket-level TTLs (see +[ADR 0014](../adr/0014-nats-state-adapter.md)). Because JetStream TTLs are +per-bucket, the dedupe and lock TTLs are fixed at construction time. The +runnable example is +[`examples/slack-nats-state`](../../examples/slack-nats-state/README.md). + +## One Namespace Per Bot Application + +The `Prefix`/`Namespace` options default to `chat`. If two *independent* bot +applications share one Redis, Postgres, or NATS service with the default, +their subscription, dedupe, and lock records collide — thread IDs carry +platform tenant/channel identity but no application identity, so app A +subscribing a thread can route that thread's follow-ups into app B's +`OnSubscribedMessage`, and one app's locks can suppress the other's events. +Give every bot application its own stable namespace, shared only by that +app's replicas (replicas must share the namespace — that is what makes +dedupe and locking work across them). + +## How To Decide + +- Writing tests or following the tutorial: use memory. +- Already running Redis: use Redis. Same for Postgres and NATS — the backends + are contract-equivalent, so pick the one you already operate. +- Running more than one bot replica: any of the durable backends works; all + three implement the same token-owned lock lease and dedupe contract, which is + what makes horizontal scaling safe. + +All backends are exercised by the same conformance suite; Redis and Postgres +integration tests run against real backends via Testcontainers, and NATS tests +run against an embedded JetStream server. diff --git a/docs/how-to/deferred-dispatch.md b/docs/how-to/deferred-dispatch.md new file mode 100644 index 0000000..df95a51 --- /dev/null +++ b/docs/how-to/deferred-dispatch.md @@ -0,0 +1,106 @@ +# How To Defer Long-Running Work (Ack-Then-Work) + +Chat platforms expect webhooks to be acknowledged quickly — Slack retries +after 3 seconds, Linear expects agent activity within ~10 seconds. If your +handler calls an LLM or does anything slow, the default synchronous dispatch +mode will run it *before* acknowledging the webhook, and the platform will +retry or time out. + +`DispatchDeferred` splits dispatch in two (see +[ADR 0002](../adr/0002-async-dispatch.md)): + +1. **Prelude, before ack**: signature verification, normalization, dedupe + marking, and thread lock acquisition run synchronously on the request + context. +2. **Detached tail, launched at ack time**: your handler runs on a + runtime-managed detached work context, concurrently with the webhook + response — the acknowledgement no longer waits on your handler (though the + tail may begin before the 2xx is actually written). The runtime renews the + thread lock lease in the background while the handler runs. If the state + backend fails to extend the lease (an error or a lost lease), renewal + stops and is logged/observed, but the handler keeps running **without + exclusivity** — after the original `ThreadLockTTL` expires, another event + on the same thread can acquire the lock and run concurrently. Long + handlers should therefore be idempotent or tolerate overlap under state + backend failures. + +## Enable It + +`WithRuntimeOptions` replaces the whole options struct (it does not merge), so +start from `chat.DefaultRuntimeOptions()` to keep the required `DedupeTTL` and +`ThreadLockTTL` defaults: + +```go +opts := chat.DefaultRuntimeOptions() +opts.Dispatch = chat.DispatchDeferred +opts.DetachTimeout = 5 * time.Minute +opts.Concurrency = chat.ConcurrencyQueue + +bot, err := chat.New(ctx, + chat.WithState(state), + chat.WithAdapter(adapter), + chat.WithRuntimeOptions(opts), +) +``` + +- `Dispatch: chat.DispatchDeferred` turns on ack-then-work. The default is + `chat.DispatchSync`, which runs the handler before acknowledging. +- `DetachTimeout` bounds how long a detached handler may run after the webhook + request has ended. +- `Concurrency: chat.ConcurrencyQueue` is the natural companion: while a + detached handler holds the thread lock, follow-up events on the same thread + wait instead of being dropped, and only the most recent superseded follow-up + runs. The default `chat.ConcurrencyDrop` acknowledges and drops conflicting + events instead. Two caveats: + - Coalescing is **per process**: with multiple bot replicas, the shared + state lock still serializes handlers, but follow-ups that landed on + different replicas each run in turn. If superseded events must never + execute twice, route each thread's webhooks to one replica or make + handlers idempotent. + - A queued follow-up's `DetachTimeout` clock starts when it is accepted, + **before** it waits for the lock. Time spent queued behind a long handler + consumes the follow-up's own budget; if the wait exhausts it, the + follow-up is cancelled without running (it was already deduped, so it + will not be redelivered). Size `DetachTimeout` to cover your longest + handler *plus* the queue wait behind it. + +## Write Handlers For The Detached Context + +Your handler code does not change shape — it still receives a +`context.Context` — but under `DispatchDeferred` that context is the detached +work context, not the HTTP request context: + +```go +bot.OnNewMention(func(ctx context.Context, ev *chat.MessageEvent) error { + // The acknowledgement is not waiting on you. Take your time (within + // DetachTimeout): call the LLM, run tools, then post. + answer, err := generate(ctx, ev.Message.Text) + if err != nil { + return err + } + _, err = ev.Thread.Post(ctx, chat.Markdown(answer)) + return err +}) +``` + +Rules that keep this safe: + +- Use the `ctx` you are given for every call. It carries the detach timeout + and is how the runtime signals cancellation. +- Handler errors after ack are recorded and observed, not retried by the + platform. If your work must not be lost, make it idempotent and consider + your own queue. +- `Shutdown(ctx)` cancels the detached work contexts first, then waits + (bounded by the context you pass it) for handlers to observe cancellation + and return. In-flight generation is aborted, not completed — deferred + dispatch is not a durable queue, so work that must survive a rolling deploy + belongs in application-owned persistence. + +## When Not To Use It + +Stay with `DispatchSync` when handlers are fast (a quick reply, a state +lookup) — synchronous dispatch keeps the failure story simpler because a +handler error still happens before the platform ack. Streaming token +transports are a non-goal of the core runtime; deferred dispatch plus one +finished message is the supported long-generation pattern (see +[ADR 0011](../adr/0011-resumable-streaming.md)). diff --git a/docs/how-to/interactive-components.md b/docs/how-to/interactive-components.md new file mode 100644 index 0000000..8e11ec5 --- /dev/null +++ b/docs/how-to/interactive-components.md @@ -0,0 +1,158 @@ +# How To Handle Interactive Components + +Buttons and menus are Interaction Events: normalized events with their own +single-slot hook, `OnInteraction`, riding the same dispatch spine as messages +(see [ADR 0004](../adr/0004-interactive-components.md)). This slice covers +Slack `block_actions` on **messages** — button clicks and menu selections on +Block Kit content posted to a channel, thread, or DM. `block_actions` +originating inside a modal view carry a view container without a +channel/message anchor and are not normalized yet; they are rejected before +routing. + +There is deliberately no cross-platform card DSL. Portable posting stays plain +text and portable Markdown; platform-native rich content (Block Kit) is posted +as an opaque payload through typed adapter access. + +## Configure Slack + +In your Slack app dashboard, under **Interactivity & Shortcuts**, enable +interactivity and set the **Request URL** to your existing webhook: + +```text +https://YOUR_PUBLIC_HOST/webhooks/slack +``` + +## Post Something Clickable + +Block Kit content is `NativeContent`, posted through the Slack adapter's +`NativeContentPoster` capability: + +```go +slackAdapter, ok := chat.AdapterAs[*slack.Adapter](bot, "slack") +if !ok { + return errors.New("slack adapter is not registered") +} + +ref, err := slackAdapter.ValidateThreadID(ev.Thread.ID()) +if err != nil { + return err +} + +sent, err := slackAdapter.PostNative(ctx, ref, chat.NativeContent{ + Adapter: "slack", + Payload: []any{ + map[string]any{ + "type": "actions", + "elements": []any{ + map[string]any{ + "type": "button", + "action_id": "approve", + "text": map[string]any{"type": "plain_text", "text": "Approve"}, + }, + }, + }, + }, +}) +if err != nil { + return err +} +_ = sent // sent.ID identifies the posted message, like portable posting +``` + +The payload is opaque to the runtime: the adapter neither validates nor +translates it. A `NativeContent` whose `Adapter` does not match the target +adapter is an error, never a silent portable downgrade. + +## Handle The Click + +```go +bot.OnInteraction(func(ctx context.Context, ev *chat.InteractionEvent) error { + switch ev.Interaction.ActionID { + case "approve": + _, err := ev.Thread.Post(ctx, chat.Text( + "Approved (by user " + ev.Interaction.Actor.ID + ")", + )) + return err + default: + return nil + } +}) +``` + +`ev.Interaction.Kind` is `chat.InteractionBlockAction` for this slice, and +`ev.Interaction.Raw` preserves the full Slack payload — including +`response_url`, `trigger_id`, action values, and view state — as the platform +escape hatch. Be aware that the concrete payload type behind `Raw` is +unexported: the raw-accepting adapter methods (`RespondURL`, +`OpenModalFromRaw`) consume it directly, but reading a menu's *selected +option value* is not yet possible without re-parsing the webhook JSON +yourself (tracked in [#46](https://github.com/coder/chat/issues/46)). Design +around distinct `action_id`s where you can until #46 lands. + +The normalized `Actor` carries the Slack user ID, not a display name (the +interactivity payload does not include one). Note that plain `chat.Text` is +posted with Slack formatting disabled, so `<@USERID>` mention syntax renders +literally. For plain-text *attribution*, resolve the display name via the +Slack API and include it as ordinary text; for a real, clickable Slack +mention, post native Block Kit content with an `mrkdwn` text element +containing `<@USERID>`. + +**Known limitation:** the interaction event identity is currently anchored on +the message timestamp, not the individual activation — so when the same user +activates the same `action_id` on the same message more than once within +`DedupeTTL` (default 24 hours), only the first activation reaches +`OnInteraction`; the rest are dropped as duplicates. This also affects a menu +whose options share one action ID. Tracked in +[#43](https://github.com/coder/chat/issues/43). Until it lands, give +repeat-activatable controls distinct `action_id`s (or replace the message's +blocks after each click). + +Mind the acknowledgement timing: under the default `DispatchSync` mode the +adapter writes the empty 2xx only *after* your handler returns, so a slow +handler can miss Slack's 3-second acknowledgement budget. Enable +[deferred dispatch](deferred-dispatch.md) (with `chat.ConcurrencyQueue`) so +the acknowledgement is not blocked on your handler — the handler moves to a +detached tail launched at ack time. + +## Open A Modal + +The Slack adapter opens modals via `views.open` straight from the escape +hatch — pass the event's `Raw` value and the adapter extracts the preserved +`trigger_id` itself: + +```go +err := slackAdapter.OpenModalFromRaw(ctx, ev.Interaction.Raw, modalView) +``` + +In multi-tenant mode (`InstallStore` configured), use the tenant-aware +variant with the event's tenant so the right workspace token is resolved: + +```go +err := slackAdapter.OpenModalForTenantFromRaw(ctx, ev.Event.Tenant, ev.Interaction.Raw, modalView) +``` + +(`OpenModal` / `OpenModalForTenant` remain for callers that already hold a +`trigger_id` string.) The same `Raw` values work from `ev.Command.Raw` in a +slash-command handler. + +Slack invalidates `trigger_id` after 3 seconds, so open modals promptly — +and note that under `chat.ConcurrencyQueue` a queued interaction waits for +the thread lock *before* your handler runs, so an interaction that queues +behind a long-running handler can arrive with its `trigger_id` already +expired and the modal open fails no matter how promptly the handler calls +it. Keep handlers on modal-bearing threads fast, or accept that modals from +lock-contended interactions may fail. + +Modal *submissions* are not part of this slice: the synchronous +`view_submission` response (`response_action`) requires responding in the +webhook's HTTP response body, which is incompatible with ack-then-work, so +the adapter acknowledges `view_submission` payloads and drops them — +application code cannot observe submitted view values today. + +## Respond Via response_url + +For ephemeral-style responses to the person who clicked: + +```go +err := slackAdapter.RespondURL(ctx, ev.Interaction.Raw, chat.Text("Working on it.")) +``` diff --git a/docs/how-to/linear-agent-sessions.md b/docs/how-to/linear-agent-sessions.md new file mode 100644 index 0000000..79a731c --- /dev/null +++ b/docs/how-to/linear-agent-sessions.md @@ -0,0 +1,169 @@ +# How To Run Linear Agent Sessions + +The Linear adapter (experimental) turns Linear agent sessions into normal +Chat SDK Go threads: when a user mentions or delegates to your Linear app, a +session event arrives as a `MessageEvent`, and `Thread.Post` sends an agent +activity response. Beyond that portable surface, the full agent activity +vocabulary — thoughts, responses, actions, elicitations, and errors, plus +session updates carrying plans and external URLs — is exposed +through typed adapter access (see [ADR 0001](../adr/0001-linear-app-actor-slice.md), +[ADR 0008](../adr/0008-linear-full-adapter.md), and +[ADR 0013](../adr/0013-linear-generic-comments.md)). + +Start from the runnable example: +[`examples/linear-agent-hello-world`](../../examples/linear-agent-hello-world/README.md) +walks through the Linear OAuth app setup (app-actor client credentials, +webhook configuration, public HTTPS URL) and includes dogfooding notes. + +## Construct The Adapter + +```go +linearAdapter, err := linear.New(ctx, linear.Options{ + WebhookSecret: os.Getenv("LINEAR_WEBHOOK_SECRET"), + ClientCredentials: linear.ClientCredentials{ + ClientID: os.Getenv("LINEAR_CLIENT_ID"), + ClientSecret: os.Getenv("LINEAR_CLIENT_SECRET"), + // Scopes default to: read, write, app:mentionable, app:assignable. + }, +}) +``` + +On `Init` the adapter exchanges client credentials for an app-actor token, +verifies the granted scopes, and discovers the app's own identity so +self-authored activities are filtered before routing. + +## Handle Sessions Like Any Thread + +New and prompted agent sessions route through the normal hooks. Be aware that +`Thread.Post` on an agent session thread creates an agent activity +**response** — a terminal, session-completing "here is my answer" activity — +so only post it when the answer is genuinely final: + +```go +bot.OnNewMention(func(ctx context.Context, ev *chat.MessageEvent) error { + answer, err := solve(ctx, ev.Message.Text) // your actual work + if err != nil { + return err + } + _, err = ev.Thread.Post(ctx, chat.Markdown(answer)) + return err +}) +``` + +For sessions that need visible progress before the final answer, start with a +thought instead (next sections). + +## Mind The Timing Contract + +Linear expects a first activity within roughly 10 seconds of a session event +and further activity within roughly 30 minutes. Post a quick **thought** +(`PostThought`) fast — not a response: a `response` activity is a completion +signal that ends the session, so reserve `Thread.Post` for the final answer. +Use [deferred dispatch](deferred-dispatch.md) for the real work — your +handler moves to a detached work context launched at ack time, so the webhook +acknowledgement no longer waits on it. + +## Use The Full Activity Surface + +Everything beyond a plain response goes through typed adapter access. Each +call can fail (validation, auth, rate limiting) — check every error before +issuing the next activity. + +Nonterminal activities keep the session alive and show progress: + +```go +la, ok := chat.AdapterAs[*linear.Adapter](bot, "linear") +if !ok { + return errors.New("linear adapter is not registered") +} + +// Ephemeral progress ("thinking...") activity. +if _, err := la.PostThought(ctx, ev.Thread.ID(), "Reading the issue history."); err != nil { + return err +} + +// A tool-call style action with a result. +if _, err := la.PostAction(ctx, ev.Thread.ID(), linear.ActionInput{ + Action: "ran", + Parameter: "go test ./...", + Result: "ok", +}); err != nil { + return err +} + +// Maintain the session's plan and external links. +if err := la.UpdateSession(ctx, ev.Thread.ID(), linear.AgentSessionUpdateInput{ + Plan: []linear.PlanStep{ + {Title: "Reproduce the bug", Status: "pending"}, + {Title: "Fix and test", Status: "pending"}, + }, + ReplacePlan: true, +}); err != nil { + return err +} +``` + +A session ends with exactly **one** completion signal — a response +(`Thread.Post`), an elicitation, or an error. Pick one branch; do not emit +two completions in the same turn: + +```go +if needsInput { + // Ask the user a question (optionally with a select/auth signal). + _, err := la.PostElicitation(ctx, ev.Thread.ID(), linear.ElicitationInput{ + Body: "Which environment should I deploy to?", + }) + return err +} +if buildFailed { + // Terminal failure state. + _, err := la.PostError(ctx, ev.Thread.ID(), linear.ErrorInput{ + Body: "The build failed; see the attached log.", + }) + return err +} +_, err := ev.Thread.Post(ctx, chat.Markdown(answer)) // final response +return err +``` + +Users can press **Stop** on a session. Check for it through the raw message +escape hatch: + +```go +if raw, ok := linear.RawMessageFrom(ev.Message); ok && raw.StopRequested() { + return nil // wind down gracefully +} +``` + +This check only runs when the stop event reaches your handler, and events on +one thread are serialized by the thread lock — a stop arriving while a +handler is still running cannot preempt it (`ConcurrencyDrop` discards it on +conflict; `ConcurrencyQueue` delivers it only after the in-flight handler +returns). There is no pre-lock hook, so **Linear's Stop control cannot cancel +in-flight work through this adapter today**. What you can do: structure long +sessions as short handler turns (each turn checks `StopRequested` on the +event that started it before doing more work), or receive the stop signal +out-of-band through your own channel (for example, your own Linear webhook +endpoint or admin API that sets a cancellation flag your handlers poll — +the flag must be set by something outside the runtime's serialized dispatch). + +## Generic Issue Comments + +The adapter also participates in plain Linear issue comments (outside agent +sessions): a comment that @-mentions your app arrives on a comment-backed +thread, and `Thread.Post` replies in that comment thread. Normal routing +precedence applies: the mention routes to `OnNewMention` only while the +thread is unsubscribed — in a thread you have subscribed, every comment +(mention or not) routes to `OnSubscribedMessage`, so do not put +mention-specific handling exclusively in `OnNewMention`. +Agent-activity methods (`PostThought`, `UpdateSession`, ...) are rejected on +comment threads — they only make sense inside agent sessions. + +## Known Gaps + +The Linear adapter is experimental. The Linear agent API surface it wraps is +itself in developer preview upstream, and some operations (proactive session +creation, repository suggestions, issue workflow automation) currently +require the `GraphQL` escape hatch rather than typed helpers. The tracked +list lives in +[`docs/linear-agent-capabilities.md`](../linear-agent-capabilities.md). diff --git a/docs/how-to/multi-tenant-install.md b/docs/how-to/multi-tenant-install.md new file mode 100644 index 0000000..4aa03bb --- /dev/null +++ b/docs/how-to/multi-tenant-install.md @@ -0,0 +1,128 @@ +# How To Install Into Multiple Workspaces (Multi-Tenant) + +By default an adapter is single-install: one Slack workspace or one Linear +organization, with credentials passed directly in the adapter options. +Multi-tenant mode is opt-in and lets one deployment serve many installs by +resolving per-tenant credentials at webhook time (see +[ADR 0006](../adr/0006-multi-tenant-install.md)). + +The boundary is deliberate: + +- **The runtime resolves credentials.** You provide a + `chat.InstallStore` and the adapter calls it with the platform tenant + (Slack team ID, Linear organization ID) extracted from each webhook. + Verification timing differs per adapter — see the caution below. +- **You own the OAuth web flow.** The authorize redirect, callback route, + token exchange, and install database are ordinary application HTTP routes + and storage — the runtime does not mount them. App-user account linking and + login flows stay app-owned too. + +## Implement An InstallStore + +```go +type InstallStore interface { + Lookup(ctx context.Context, adapter, tenant string) (Install, error) +} +``` + +Return `chat.ErrInstallNotFound` for tenants you do not know: the adapter +acknowledges the event and ignores it (an uninstalled workspace is not an +error). Any other error is treated as a transport failure and surfaces as a +5xx so the platform retries. + +```go +type installStore struct{ db *sql.DB } + +func (s *installStore) Lookup(ctx context.Context, adapter, tenant string) (chat.Install, error) { + row, err := s.queryInstall(ctx, adapter, tenant) + if errors.Is(err, sql.ErrNoRows) { + return chat.Install{}, chat.ErrInstallNotFound + } + if err != nil { + return chat.Install{}, err + } + return chat.Install{ + Tenant: tenant, + Credential: slack.SlackInstall{ + BotToken: row.BotToken, + BotUserID: row.BotUserID, + }, + }, nil +} +``` + +The `Credential` field is adapter-specific: + +- Slack: `slack.SlackInstall{BotToken, BotUserID}` +- Linear: `linear.LinearInstall{WebhookSecret, ClientCredentials, AccessToken, BotUserID}` + (either client credentials for token exchange or a pre-exchanged access + token) + +In multi-tenant mode the adapter does not discover the app's identity per +install, so treat the per-install bot identity as **required** on both +adapters: + +- Slack: without `SlackInstall.BotUserID` (or `Install.BotActorID`), + self-message filtering has no identity to match — if you subscribe to + `message.channels` or `message.im`, the bot's own posts re-enter routing + and a subscribed thread can loop (reply triggers `OnSubscribedMessage`, + which replies again). Slack's `oauth.v2.access` response includes the + `bot_user_id`; store it on the install record. +- Linear: without `LinearInstall.BotUserID` (or `Install.BotActorID`), + mention detection and self-comment filtering for generic comment + participation have nothing to match — the app never sees its own + @-mentions as mentions, and may route its own comments back to itself. + Capture the app user ID during your OAuth flow (e.g. query `viewer { id }` + with the freshly exchanged token) and store it. + +## Construct The Adapter In Multi-Tenant Mode + +`InstallStore` is mutually exclusive with the single-install credential +options: + +```go +slackAdapter, err := slack.New(ctx, slack.Options{ + SigningSecret: os.Getenv("SLACK_SIGNING_SECRET"), // shared across installs + InstallStore: store, +}) +``` + +```go +linearAdapter, err := linear.New(ctx, linear.Options{ + InstallStore: store, // per-install webhook secrets and credentials +}) +``` + +For Slack, the signing secret is app-level and shared; signature verification +happens before any store lookup, so the tenant your store sees came from a +verified request. For Linear, the webhook secret is itself per-install, so +the adapter must parse the organization ID from the **unverified** body and +call `Lookup` first to fetch the secret it verifies with. Treat the Linear +tenant argument as untrusted routing input: keep `Lookup` a cheap indexed +read, do not let unknown tenants trigger expensive work, and rely on +`ErrInstallNotFound` (not errors) for tenants you do not know. + +## Wire Up Your OAuth Flow + +Sketch of the app-owned part for Slack: + +1. Mount `/slack/install` — redirect to Slack's OAuth authorize URL with your + client ID and scopes. +2. Mount `/slack/oauth/callback` — exchange the code via `oauth.v2.access`, + then store the returned team ID, bot token, and bot user ID in your + install database. +3. Your `InstallStore.Lookup` reads that row. + +Uninstalls: delete the row; subsequent events from that tenant resolve to +`ErrInstallNotFound` and are acknowledged and ignored. + +## What Stays Tenant-Correct Automatically + +Thread IDs, actors, and dedupe keys all carry the platform tenant, so two +workspaces never collide in runtime state. Thread handle reconstruction +(`bot.Thread(ctx, threadID)`) decodes and validates the stored ID without +touching the install store; the credential lookup for the stored tenant +happens when the reconstructed handle actually posts. Proactive posts work +across installs without extra plumbing — but a successful `bot.Thread` call +is not proof the tenant is still installed; an uninstalled tenant surfaces as +an error from the post. diff --git a/docs/how-to/slash-commands.md b/docs/how-to/slash-commands.md new file mode 100644 index 0000000..cb31711 --- /dev/null +++ b/docs/how-to/slash-commands.md @@ -0,0 +1,95 @@ +# How To Handle Slash Commands + +A slash command is a Command Event, not a message: it rides the same dispatch +spine (dedupe, thread lock, tenant scoping) but routes to its own single-slot +hook, `OnCommand`, regardless of thread subscription state (see +[ADR 0003](../adr/0003-slash-commands.md)). This slice covers Slack slash +commands. + +## Configure Slack + +In your Slack app dashboard, under **Slash Commands**, create the command +(for example `/deploy`) and set its **Request URL** to the same webhook you +already mounted: + +```text +https://YOUR_PUBLIC_HOST/webhooks/slack +``` + +The Slack adapter acknowledges the command with an empty 2xx; under the +default synchronous dispatch mode that happens after your handler returns +(see [Long-Running Commands](#long-running-commands) for staying inside +Slack's 3-second budget). + +## Register The Handler + +Respond through the `response_url` Slack includes with every slash command, +reached via the Slack adapter's `RespondURL`: + +```go +slackAdapter, ok := chat.AdapterAs[*slack.Adapter](bot, "slack") +if !ok { + return errors.New("slack adapter is not registered") +} + +bot.OnCommand(func(ctx context.Context, ev *chat.CommandEvent) error { + switch ev.Command.Name { + case "/deploy": + return slackAdapter.RespondURL(ctx, ev.Command.Raw, chat.Text( + "Deploying "+strings.Join(ev.Command.Args, " "), + )) + default: + return nil + } +}) +``` + +Why not `ev.Thread.Post`? A slash command in a channel carries no message +timestamp, so its thread is rooted at the channel itself — there is no thread +to post into, and a regular threaded post to that synthetic root fails. +`RespondURL` is the channel-command response path (Slack renders it in place, +ephemeral by default). In a direct-message conversation with the bot, +`ev.Thread.Post` works normally. + +What you get on `ev.Command`: + +- `Name` — the command, including the slash (`/deploy`). +- `Text` — the raw argument text after the command name. +- `Args` — an advisory whitespace split of `Text`. +- `Actor` — the human who invoked the command. +- `Raw` — the platform escape hatch, preserving Slack's `response_url` and + `trigger_id` for native responses (see the + [interactive components guide](interactive-components.md)). + +## Routing Rules Worth Knowing + +- Command-ness wins: a command typed in a subscribed thread routes to + `OnCommand`, never to `OnSubscribedMessage`. +- A command does not auto-subscribe its thread. +- `OnCommand` is single-slot like the message hooks: registering again + atomically replaces the handler, and an unset handler is a no-op that still + acknowledges the platform. +- Commands are deduped by event identity and take a thread lock on the + command's own thread scope. In a channel that scope is the synthetic + channel-rooted thread, which is distinct from every message thread's scope — + so do not rely on a channel command serializing with message handlers. + Direct-message commands share the DM conversation's thread scope. + +## Long-Running Commands + +Under the default `DispatchSync` mode your handler runs before the platform +acknowledgement, so slow command work risks Slack's 3-second timeout. Enable +[deferred dispatch](deferred-dispatch.md) so the acknowledgement no longer +waits on your handler, and consider `chat.ConcurrencyQueue` so mid-work +commands and clicks queue instead of dropping. Slack keeps a command's `response_url` +valid for 30 minutes, so a deferred handler can finish its work and respond +through `RespondURL` afterwards. + +One coalescing caveat: because every channel command shares the synthetic +channel-rooted scope, the queue keeps only the single most recent pending +command per channel — while one command runs, *independent* commands from +other users (or other command names) in the same channel supersede each +other, and all but the newest are acknowledged without invoking `OnCommand`. +If your bot expects concurrent channel commands, keep command handlers fast +(ack the command, hand real work to your own queue keyed by `response_url`) +rather than holding the thread lock through long work. diff --git a/docs/linear-agent-capabilities.md b/docs/linear-agent-capabilities.md index 29316bb..20cd9c7 100644 --- a/docs/linear-agent-capabilities.md +++ b/docs/linear-agent-capabilities.md @@ -1,301 +1,127 @@ # Linear Agent Capabilities -Status: tracking document for the Linear app-actor adapter. +Status: tracking document for the Linear adapter (`experimental`). -This document compares the Linear app-actor adapter against Linear's current agent documentation: +This document compares the Linear adapter against Linear's agent +documentation: - Developing the Agent Interaction: https://linear.app/developers/agent-interaction - Signals: https://linear.app/developers/agent-signals - Interaction Best Practices: https://linear.app/developers/agent-best-practices - Getting Started: https://linear.app/developers/agents -The adapter currently implements the minimum runtime slice needed to receive Linear `AgentSessionEvent` webhooks and respond in agent sessions. A production-quality Linear agent integration needs more of Linear's agent APIs, either through first-class typed helpers or through a deliberate GraphQL escape hatch. +The adapter implements the full agent activity surface (ADR 0008), generic +issue/comment participation (ADR 0013), rate-limit retry (ADR 0005), and +multi-tenant installs (ADR 0006). Linear's Agent API is itself in Developer +Preview upstream and may change. The remaining gaps below are operations a +production-quality agent may need that currently require the `GraphQL` escape +hatch rather than typed helpers. ## Current Support | Linear capability | Current support | Notes | | --- | --- | --- | | App actor auth with client credentials | Supported | Default scopes include `read`, `write`, `app:mentionable`, and `app:assignable`; startup verifies Linear granted all requested scopes. | -| Agent session webhooks | Partially supported | Handles `AgentSessionEvent` `created` and `prompted`, including Linear-created assignment/delegation sessions. | +| Multi-tenant installs | Supported | Per-install webhook secrets and client credentials or pre-exchanged access tokens through `chat.InstallStore` (ADR 0006). Per-tenant lazy token refresh applies to client-credential installs only; a pre-exchanged `AccessToken` is used as-is until the install store supplies a replacement. | +| Agent session webhooks | Supported | Handles `AgentSessionEvent` `created` and `prompted`, including Linear-created assignment/delegation sessions. | +| Generic issue/comment participation | Supported | Comments that @-mention the app arrive on comment-kind threads (routing to `OnNewMention` while unsubscribed); `Thread.Post` replies as an issue comment (ADR 0013). | | Inbox notification webhooks | Not normalized | Ignored by the adapter, matching upstream Chat SDK. | -| Mention-created sessions | Supported | Created sessions with `agentSession.comment` route to `OnNewMention`. | +| Mention-created sessions | Supported | Created sessions with `agentSession.comment` route to `OnNewMention` (on unsubscribed threads; normal routing precedence applies — a subscribed thread routes everything to `OnSubscribedMessage`). | | Delegation-created sessions | Supported | Created sessions without `agentSession.comment` route to `OnNewMention` using `promptContext` and session id fallbacks. | | Follow-up prompts | Supported | Prompted events route according to runtime subscription state and read `agentActivity.body` with a content-body fallback. | -| First thought / acknowledgement | Supported narrowly | `Adapter.PostThought` creates an ephemeral `thought` activity. | -| Final response | Supported narrowly | `Thread.Post` creates a `response` activity. | -| Thread reconstruction | Supported | Stored Linear agent-session `ThreadID`s can reconstruct a `Thread` for later posting. | +| Agent activities (all five content types) | Supported | `CreateAgentActivity` sends `thought`, `elicitation`, `action`, `response`, and `error` with `signal`, `signalMetadata`, and `ephemeral` (only `thought` and `action` may be ephemeral). | +| Typed activity helpers | Supported | `PostThought`, `PostAction`, `PostElicitation`, `PostError`; `Thread.Post` creates the `response` activity. | +| Agent-to-human signals | Supported | `auth` and `select` signals with metadata pass through `CreateAgentActivity` / `PostElicitation`. | +| Human-to-agent stop signal | Supported | `RawMessageFrom(ev.Message)` exposes `Signal` / `StopRequested()`; see the routing caveat below. | +| Session updates | Supported | `UpdateSession` sets `externalUrls` and replaces the session plan array. | +| GraphQL escape hatch | Supported | `GraphQL` (single-install) and `GraphQLForTenant` (multi-tenant) reuse adapter auth and token refresh, surface GraphQL errors, and never expose tokens. | +| Rate-limit handling | Supported | Bounded retry on HTTP 429 and GraphQL `RATELIMITED` with a typed `*linear.RateLimited` error (ADR 0005). | +| Message history read-through | Supported | `chat.HistoryReader` reads agent-session activities and issue-comment threads, newest-first with `Before` paging (ADR 0009). | +| Thread reconstruction | Supported | Stored Linear `ThreadID`s (agent-session and comment kinds) reconstruct a `Thread` for later posting. | | Tenant-correct thread identity | Supported | Opaque Linear thread ids include organization, issue, optional comment, and session ids. | +| Raw payload escape hatch | Supported | `RawMessage` preserves kind, action, session context, signal, signal metadata, source comment, and the full webhook envelope. | ## Missing Capabilities To Track -### 1. General GraphQL Escape Hatch +### 1. Proactive Agent Session Creation -**Status:** Missing. +**Status:** Missing typed helpers; possible via `GraphQL`. Tracked in +[#47](https://github.com/coder/chat/issues/47). -The adapter should expose a deliberate low-level GraphQL method that reuses adapter authentication, token refresh, API base URL, HTTP client, and GraphQL error handling without exposing raw tokens. +Linear supports creating sessions when the agent was not mentioned or +delegated (`agentSessionCreateOnIssue`, `agentSessionCreateOnComment`). +A typed helper should return a session convertible into this adapter's opaque +`ThreadID`, with tests proving the created session can be posted to with +`Thread.Post` and `PostThought`. -Candidate shape: +### 2. Repository Suggestions -```go -func (a *Adapter) GraphQL(ctx context.Context, query string, variables any, dest any) error -``` +**Status:** Missing typed helpers; possible via `GraphQL`. Tracked in +[#48](https://github.com/coder/chat/issues/48). -Why this matters: +Linear exposes `issueRepositorySuggestions` for ranking candidate +repositories. A helper should cover the candidate input shape, returned +suggestions (hostname, repository full name, confidence), and pairing low +confidence with a `select` elicitation. -- Linear's Agent APIs are in Developer Preview and may change. -- The agent docs explicitly point developers to the GraphQL schema explorer and raw SDL for webhook/API types. -- A serious agent needs to call APIs before the Go adapter has typed wrappers for every agent operation. +### 3. Issue Workflow Best Practices -Acceptance notes: +**Status:** Missing typed helpers; possible via `GraphQL`. -- Must use the same client-credentials token refresh path as internal calls. -- Must surface GraphQL `errors` clearly. -- Must not expose or return the access token. -- Should be documented as a Linear-specific escape hatch, not a cross-platform API. +Linear's best practices recommend moving delegated issues to a `started` +workflow state when work begins and setting the agent as `Issue.delegate`. +This likely belongs in a higher-level helper package or example workflow, not +the core adapter. -### 2. Generic Agent Activity Creation +### 4. Stop Handling Versus Thread Serialization -**Status:** Missing, except for `PostThought` and `Thread.Post` response. +**Status:** Inherent limitation; needs an application-owned pattern. -Linear allows five agent-emitted activity content types: +The `stop` signal arrives as a prompted event on the same thread, so it is +serialized behind the thread lock like any other event: it cannot preempt a +handler that is already running (`ConcurrencyDrop` discards it during a +conflict; `ConcurrencyQueue` delivers it only after the in-flight handler +returns). There is no pre-lock interception hook, so Linear's Stop control +cannot drive active cancellation through this adapter today. Workable +patterns: split sessions into short handler turns that check `StopRequested` +at each turn boundary, or deliver the stop out-of-band (an application-owned +webhook/endpoint outside the runtime's serialized dispatch that sets a +cancellation flag handlers poll). A documented example is still to be +written. -- `thought` -- `elicitation` -- `action` -- `response` -- `error` - -The adapter should expose a generic `CreateAgentActivity` / `PostActivity` escape hatch that can send all server-validated content shapes, signals, signal metadata, and the `ephemeral` flag. - -Candidate shape: - -```go -type AgentActivityInput struct { - Content map[string]any - Signal string - SignalMetadata any - Ephemeral bool -} - -func (a *Adapter) CreateAgentActivity(ctx context.Context, threadID chat.ThreadID, input AgentActivityInput) (*chat.SentMessage, error) -``` - -Why this matters: - -- `action` activities are needed for native Linear tool-call progress. -- `elicitation` activities are needed to ask users questions. -- `error` activities are needed to end failed sessions properly. -- Only `thought` and `action` may be ephemeral, so validation or docs should make that clear. - -### 3. Typed Activity Convenience Helpers - -**Status:** Mostly missing. - -Convenience helpers can wrap generic activity creation after the escape hatch exists: - -- `PostThought` (already present) -- `PostAction` -- `PostElicitation` -- `PostError` -- possibly `PostResponse` for callers that want to bypass `Thread.Post` - -These should stay Linear-specific through `Adapter Access` until there is evidence that a cross-platform abstraction is useful. - -### 4. Agent-to-Human Signals - -**Status:** Missing. - -Linear supports signals on agent-created activities. The docs currently call out: - -- `auth` signal on `elicitation`, with `signalMetadata.url`, optional `userId`, and optional `providerName`. -- `select` signal on `elicitation`, with selectable options. - -Needed support: - -- Generic `Signal` and `SignalMetadata` fields in the activity escape hatch. -- Examples for auth/account linking. -- Examples for select/choice elicitation. - -### 5. Human-to-Agent Stop Signal - -**Status:** Missing. - -Linear can send a `stop` signal on user-generated `prompt` activities. The adapter currently normalizes prompted events to a text message but does not expose signal metadata. - -Needed support: - -- Preserve inbound `agentActivity.signal` and `signalMetadata` in `Message.Raw` or a typed Linear raw-message struct. -- Document how application code should detect a stop request. -- Recommend halting work and emitting a final `response` or `error` activity. - -### 6. Agent Session Updates - -**Status:** Missing. - -Linear uses `agentSessionUpdate` for session-level metadata. - -Needed operations: - -- Set or replace `externalUrls`. -- Add/remove external URLs. -- Publish pull request or dashboard links through `externalUrls`. -- Update the full session plan array. - -Candidate helpers: - -```go -func (a *Adapter) UpdateSession(ctx context.Context, threadID chat.ThreadID, input AgentSessionUpdateInput) error -func (a *Adapter) SetExternalURLs(ctx context.Context, threadID chat.ThreadID, urls []ExternalURL) error -func (a *Adapter) UpdatePlan(ctx context.Context, threadID chat.ThreadID, plan []PlanStep) error -``` - -Important Linear behavior: - -- Setting `externalUrls` can prevent a new session from being marked unresponsive. -- Plan updates replace the full plan array; they do not patch one plan item. - -### 7. Proactive Agent Session Creation - -**Status:** Missing. - -Linear supports creating sessions when the agent was not mentioned or delegated: - -- `agentSessionCreateOnIssue` -- `agentSessionCreateOnComment` - -Needed support: - -- Typed helpers or documented GraphQL examples. -- Returned session should be convertible into this adapter's opaque Linear `ThreadID`. -- Tests should prove a proactively-created session can be posted to with `Thread.Post` and `PostThought`. - -### 8. Repository Suggestions - -**Status:** Missing. - -Linear exposes `issueRepositorySuggestions` for ranking candidate repositories using issue, session, guidance, and Linear signals. - -Needed support: - -- Typed helper or GraphQL example. -- Candidate repository input shape. -- Returned suggestions with hostname, repository full name, and confidence. -- Example of using suggestions with a `select` elicitation when confidence is low. - -### 9. Prompt Context and Structured Session Context - -**Status:** Partial. - -The adapter now uses `promptContext` as fallback text for delegation-created sessions. It does not expose structured fields beyond raw webhook data. - -Needed support: - -- Preserve `promptContext`, `guidance`, `previousComments`, `agentSession.issue`, and `agentSession.comment` in a stable Linear raw-message escape hatch. -- Document how application code should build an LLM prompt from these fields. -- Consider a typed accessor for Linear agent-session event metadata. - -### 10. Conversation History Through Agent Activities - -**Status:** Missing. - -Linear recommends using Agent Activities for session conversation history rather than relying on editable comments alone. - -Needed support: - -- Query/list activities for a session. -- Convert activity history into an application-friendly representation. -- Preserve prompt, thought, action, elicitation, response, error, signal, and metadata fields. - -### 11. Issue Workflow Best Practices - -**Status:** Missing. - -Linear's best practices recommend workflow updates when an agent starts work. - -Needed operations: - -- Query the issue's team workflow states filtered to `started` statuses. -- Move delegated issues to the first `started` status when work begins if not already started/completed/canceled. -- If the agent is working on implementation and no `Issue.delegate` is set, set itself as delegate. - -This likely belongs in a higher-level Linear agent helper package or example workflow, not the core adapter, but the GraphQL escape hatch must make it possible. - -### 12. Best-Practice Webhook Categories +### 5. Best-Practice Webhook Categories **Status:** Partial. -The adapter does not normalize Inbox Notification or Permission Change webhooks. Assignment/delegation should enter the runtime through Linear's `AgentSessionEvent` `created` webhook: Linear creates the agent session automatically when the app actor is delegated an issue, and follow-up chat arrives as `AgentSessionEvent` `prompted`. +The adapter does not normalize Inbox Notification or Permission Change +webhooks. Assignment/delegation enters the runtime through Linear's +`AgentSessionEvent` `created` webhook. -Setup footgun: if direct mentions create sessions but assignment/delegation does not, -reinstall the app actor after confirming `app:assignable` is in the authorization -URL. Linear can keep stale install/app state after scope changes; during -dogfooding we had to delete and recreate the OAuth app before +Setup footgun: if direct mentions create sessions but assignment/delegation +does not, reinstall the app actor after confirming `app:assignable` is in the +authorization URL. Linear can keep stale install/app state after scope +changes; during dogfooding we had to delete and recreate the OAuth app before assignment-created sessions started arriving. -Upstream Vercel Chat SDK precedent, checked on May 13, 2026: - -- `@chat-adapter/linear` documents app-actor mode as driven by `AgentSessionEvent` and asks webhook setup to enable Comments, Agent session events, Issues, and optional Emoji reactions. -- Its adapter imports Linear webhook types for `AgentSessionEvent`, `Comment`, and `Reaction` and registers handlers for `OAuthApp` revocation, `Comment`, `AgentSessionEvent`, and `Reaction`. -- It does not register `AppUserNotification` or `PermissionChange` handlers, and it has no normalized callbacks for Inbox Notification or Permission Change payloads. -- Its `AgentSessionEvent` parser handles `prompted` and `created`; this Go adapter follows that routing model while tolerating created assignment/delegation payloads that omit `agentSession.comment` by using `promptContext` and session ID fallbacks. - -Needed support: - -- Keep Inbox Notification actions ignored until the adapter has a raw Linear webhook callback or a typed event with a clear runtime semantic. -- Keep Permission Change webhooks ignored until the adapter has a Linear-specific callback for installation/team-access changes; they do not map cleanly to normalized chat messages. -- Preserve enough raw payload data for advanced agents to react to permission and notification changes through future adapter extensions. - -### 13. Account Linking / Auth Flow UX - -**Status:** Missing. - -A first-class Linear agent often needs to prompt the user to link an external account. - -Needed support: - -- `elicitation` + `auth` signal helper or example. -- `signalMetadata.url` support. -- Optional target `userId` support. -- Follow-up behavior after the user completes auth, usually emitting a new `thought` and continuing work. - -### 14. Select / Choice UX - -**Status:** Missing. - -A first-class agent needs user choices for ambiguous decisions, such as selecting repositories or confirming an action. - -Needed support: - -- `elicitation` + `select` signal helper or example. -- Option metadata shape once confirmed against the GraphQL schema. -- Handling the prompted follow-up generated by the user's selection. - -### 15. PR / External Work Links - -**Status:** Missing. - -Agents should link users to external work surfaces, especially pull requests or dashboards. - -Needed support: +Upstream Vercel Chat SDK precedent, checked on May 13, 2026: its Linear +adapter registers handlers for `OAuthApp` revocation, `Comment`, +`AgentSessionEvent`, and `Reaction`, and has no normalized callbacks for +Inbox Notification or Permission Change payloads. This adapter follows that +model. Reaction webhooks are not normalized here either. -- `externalUrls` session update helper or documented GraphQL example. -- Example for publishing a pull request URL. -- Guidance that the URL list is replaced as a whole unless using add/remove fields. +### 6. UX Example Coverage -## Proposed Next Implementation Slice +**Status:** Docs gap. Tracked in [#49](https://github.com/coder/chat/issues/49). -The next slice should focus on escape hatches before building many typed wrappers: +The mechanics for auth elicitation (`signalMetadata.url`, optional `userId`), +select elicitation, and PR/dashboard links via `externalUrls` are all +implemented, but worked examples (including the follow-up behavior after a +user completes auth or makes a selection) are still to be written. -1. Public `GraphQL` method on the Linear adapter. -2. Public generic `CreateAgentActivity` method with `content`, `signal`, `signalMetadata`, and `ephemeral` support. -3. Preserve inbound `signal`, `signalMetadata`, and `promptContext` in a stable Linear raw-message shape. -4. Add README examples for: - - `agentSessionUpdate` external URLs. - - plan updates. - - auth elicitation. - - select elicitation. - - repository suggestions. - - proactive session creation. -5. Add tests for GraphQL auth reuse, generic activity payload pass-through, signal preservation, and docs examples. +## Planned Work -After that escape-hatch slice is dogfooded, add typed convenience helpers for the most common operations. +Future work is sequenced on the public issue tracker, not in this document: +[#47](https://github.com/coder/chat/issues/47) (proactive session creation), +[#48](https://github.com/coder/chat/issues/48) (repository suggestions), and +[#49](https://github.com/coder/chat/issues/49) (worked UX examples). This page +tracks current capability status only. diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..2fb4a33 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,81 @@ +# API And Package Reference + +The API reference is the GoDoc. Every package carries package-level +documentation (`doc.go`), and the intentional differences from Vercel Chat +SDK are documented directly on the symbols they affect. + +## Modules And Packages + +| Package | Module | GoDoc | +| --- | --- | --- | +| `github.com/coder/chat` | core | [pkg.go.dev](https://pkg.go.dev/github.com/coder/chat) | +| `github.com/coder/chat/adapters/slack` | core | [pkg.go.dev](https://pkg.go.dev/github.com/coder/chat/adapters/slack) | +| `github.com/coder/chat/adapters/linear` | core | [pkg.go.dev](https://pkg.go.dev/github.com/coder/chat/adapters/linear) | +| `github.com/coder/chat/state/memory` | core | [pkg.go.dev](https://pkg.go.dev/github.com/coder/chat/state/memory) | +| `github.com/coder/chat/state/redis` | separate | [pkg.go.dev](https://pkg.go.dev/github.com/coder/chat/state/redis) | +| `github.com/coder/chat/state/postgres` | separate | [pkg.go.dev](https://pkg.go.dev/github.com/coder/chat/state/postgres) | +| `github.com/coder/chat/state/nats` | separate | [pkg.go.dev](https://pkg.go.dev/github.com/coder/chat/state/nats) | + +Redis, Postgres, and NATS state live in separate Go modules so applications +that only use core, Slack, or memory state do not pull their dependencies. +The repository uses `go.work` for local development across all modules. + +To browse the reference locally without pkg.go.dev: + +```sh +go doc github.com/coder/chat +go doc github.com/coder/chat/adapters/slack +``` + +## Where To Look For What + +- **Runtime construction, hooks, dispatch, runtime options**: package `chat` + (`chat.New`, `Chat.OnNewMention`, `Chat.OnSubscribedMessage`, + `Chat.OnCommand`, `Chat.OnInteraction`, `RuntimeOptions`). +- **Event and message model**: package `chat` (`Event`, `Message`, + `MessageEvent`, `CommandEvent`, `InteractionEvent`, `Thread`, `ThreadID`, + `Actor`). +- **Optional capabilities**: package `chat` (`NativeContentPoster`, + `HistoryReader`, `EphemeralPoster` interfaces) plus the adapter packages + for what each adapter actually implements. +- **Multi-tenant installs**: package `chat` (`InstallStore`, `Install`, + `ErrInstallNotFound`) plus `slack.SlackInstall` / `linear.LinearInstall`. +- **State contract**: package `chat` (`State`) with implementations in the + four `state/*` packages. + +## Adapter Capability Status + +Portable behavior (normalized events, thread routing, `Thread.Post`, +`Thread.Subscribe`) works on every adapter. Optional capabilities and +platform-specific surfaces differ: + +| Capability | Slack | Linear | +| --- | --- | --- | +| Message events (mentions, subscribed threads, DMs) | Yes | Yes (agent sessions and issue comments) | +| Ephemeral messages with explicit DM fallback (`Thread.PostEphemeral`) | Yes | No (unsupported capability); Linear-specific ephemeral *thoughts* via `PostThought` on agent-session threads | +| Slash commands (`OnCommand`) | Yes | No (no platform equivalent) | +| Interactive components (`OnInteraction`) | Yes (message `block_actions`; modal-view actions are not normalized) | No | +| Native content posting (`NativeContentPoster`) | Yes (Block Kit) | No | +| Modal open / `response_url` | Yes (`OpenModalFromRaw`, `OpenModal`, `RespondURL`) | No | +| Message history read-through (`HistoryReader`) | Yes | Yes (agent-session activities and issue-comment threads) | +| Rate-limit retry with typed `RateLimited` error | Yes | Yes | +| Multi-tenant installs (`InstallStore`) | Yes | Yes | +| Platform escape hatch | Raw payloads on events | `RawMessage`, `GraphQL` | +| Agent activities (thought/response/action/elicitation/error) | n/a | Yes | +| Session updates (plan, external URLs) | n/a | Yes (`UpdateSession`) | + +For the tracked list of Linear agent APIs that are not yet wrapped in typed +helpers, see [linear-agent-capabilities.md](linear-agent-capabilities.md). + +## Examples + +Runnable, documented examples live in [`examples/`](../examples/): + +- [`slack-hello-world`](../examples/slack-hello-world/README.md) — memory + state, no infrastructure (the [tutorial](tutorials/slack-bot.md) target). +- [`slack-redis-state`](../examples/slack-redis-state/README.md), + [`slack-postgres-state`](../examples/slack-postgres-state/README.md), + [`slack-nats-state`](../examples/slack-nats-state/README.md) — the same bot + on each durable state backend, each with a `compose.yaml`. +- [`linear-agent-hello-world`](../examples/linear-agent-hello-world/README.md) — + Linear agent sessions with memory state. diff --git a/docs/tutorials/slack-bot.md b/docs/tutorials/slack-bot.md new file mode 100644 index 0000000..d98b949 --- /dev/null +++ b/docs/tutorials/slack-bot.md @@ -0,0 +1,214 @@ +# Tutorial: Your First Slack Bot + +In this tutorial you take Chat SDK Go from zero to a running Slack bot that +replies when you mention it. You will create a Slack app, run the bundled +hello-world example against your workspace, and then make your first change to +the bot's behavior. + +Expect the whole tutorial to take under 30 minutes. + +## What You Need + +- Go 1.26.3 or newer (`go version`). +- A Slack workspace where you are allowed to create and install apps. +- A way to expose local port 8080 to the public internet over HTTPS, such as + [Tailscale Funnel](https://tailscale.com/kb/1223/funnel), `ngrok`, or + `cloudflared`. Slack delivers events by calling your bot over HTTPS. + +You do not need Docker, Redis, Postgres, or any other service: this tutorial +uses the in-memory state backend. + +## Step 1: Clone And Build + +Clone the repository and make sure the example compiles: + +```sh +git clone https://github.com/coder/chat.git +cd chat +go build ./examples/slack-hello-world +``` + +If `go build` succeeds, your toolchain is ready. + +The example you are about to run lives in +[`examples/slack-hello-world/main.go`](../../examples/slack-hello-world/main.go). +Its whole job is: + +1. Build a Slack adapter from a signing secret and a bot token. +2. Build a `chat.Chat` runtime with in-memory state and that adapter. +3. Register one handler: when the bot is mentioned, reply with + `**hello** _world_` in the same thread. +4. Serve the Slack webhook on `http://localhost:8080/webhooks/slack`. + +## Step 2: Create A Slack App + +Open the Slack app dashboard and create a new app (from scratch) in your +workspace: + + + +Then configure it: + +1. In **OAuth & Permissions**, under **Bot Token Scopes**, add: + + | Scope | Why the bot needs it | + | --- | --- | + | `chat:write` | Post the reply with `chat.postMessage`. | + | `app_mentions:read` | Receive `app_mention` events when the bot is mentioned. | + | `im:history` | Only needed if you also want direct messages to reach the bot. | + +2. In **App Home**, under **Show Tabs**, enable the **Messages Tab** and allow + users to send messages from it (Slack labels this "Allow users to send + Slash commands and messages from the messages tab"). This matters only for + direct messages; mentions in channels work without it. + +3. In **OAuth & Permissions**, click **Install to Workspace** and approve the + app. + +## Step 3: Collect Credentials + +You need two secrets. Treat both like passwords. + +1. In **OAuth & Permissions**, copy the **Bot User OAuth Token**. It starts + with `xoxb-`. This becomes `SLACK_BOT_TOKEN`. +2. In **Basic Information**, under **App Credentials**, copy the + **Signing Secret**. This becomes `SLACK_SIGNING_SECRET`. + +## Step 4: Run The Bot + +From the repository root: + +```sh +export SLACK_SIGNING_SECRET="..." +export SLACK_BOT_TOKEN="xoxb-..." +export CHAT_DEMO_IN_MEMORY_STATE=1 +export PORT=8080 + +go run ./examples/slack-hello-world +``` + +`CHAT_DEMO_IN_MEMORY_STATE=1` is a deliberate speed bump: it acknowledges that +in-memory state is lost on restart, which is fine for this tutorial and wrong +for production. The [state backend guide](../how-to/choose-a-state-backend.md) +covers the durable options. + +On startup the adapter calls Slack's `auth.test` with your bot token to +discover the bot's own identity. If the token is wrong you find out now, not +on the first message. When the bot is up you should see a log line like: + +```text +level=INFO msg=listening addr=:8080 +``` + +## Step 5: Expose The Bot To Slack + +Slack must be able to reach your machine over public HTTPS. In a second +terminal, expose port 8080 with your tunnel of choice. For example, with +Tailscale Funnel: + +```sh +tailscale funnel --bg --https=443 localhost:8080 +tailscale funnel status +``` + +or with ngrok: + +```sh +ngrok http 8080 +``` + +Either way you end up with a public HTTPS URL such as +`https://your-host.example.com`. Keep the tunnel running. + +## Step 6: Subscribe To Events + +Back in the Slack app dashboard: + +1. In **Event Subscriptions**, enable events. +2. Set the **Request URL** to: + + ```text + https://YOUR_PUBLIC_HOST/webhooks/slack + ``` + + Slack immediately sends a `url_verification` challenge. The Slack adapter + answers it automatically; the dashboard should show **Verified** within a + few seconds. If it does not, check that the bot from Step 4 and the tunnel + from Step 5 are both still running. + +3. Under **Subscribe to bot events**, add `app_mention` (and `message.im` if + you added `im:history` in Step 2). +4. Save changes. If Slack prompts you to reinstall the app, do it from + **OAuth & Permissions**. + +## Step 7: Talk To Your Bot + +In Slack, invite the bot to a channel and mention it: + +```text +/invite @your-bot +@your-bot hello +``` + +The bot replies in a thread on your message with `**hello** _world_`, +rendered with bold and italics. You have a running Slack bot. + +## Step 8: Make It Yours + +The bot currently answers every mention but forgets the conversation +immediately. Make it stay in the conversation. + +First, let Slack deliver unmentioned channel messages to your bot — without +this, only mentions ever reach it: + +1. In **OAuth & Permissions**, add the `channels:history` bot scope. +2. In **Event Subscriptions**, add the `message.channels` bot event. +3. Reinstall the app from **OAuth & Permissions**. + +(If you set up `message.im` in Step 2, you can skip this and test the +follow-up flow in a direct message instead.) + +Then open `examples/slack-hello-world/main.go` and replace the +`OnNewMention` handler registration with: + +```go +bot.OnNewMention(func(ctx context.Context, ev *chat.MessageEvent) error { + if err := ev.Thread.Subscribe(ctx); err != nil { + return err + } + _, err := ev.Thread.Post(ctx, chat.Markdown("**hello** _world_")) + 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 +}) +``` + +Restart the bot (`Ctrl-C`, then `go run ./examples/slack-hello-world` again) +and mention it once more. From then on, follow-up messages in that thread get +echoed back — no mention required. (If you type several messages faster than +the bot replies, some may be skipped: the default concurrency strategy drops +events that arrive while the thread's previous event is still being handled.) +Two things to notice: + +- Replying never subscribes a thread. `Thread.Subscribe` is always an explicit + decision, and it lasts until you call `Thread.Unsubscribe`. +- Subscriptions live in runtime state. Because this example uses in-memory + state, restarting the bot forgets them. + +## Where To Go Next + +- [Choose a state backend](../how-to/choose-a-state-backend.md) to keep + subscriptions, dedupe marks, and locks across restarts. +- [Defer long-running work](../how-to/deferred-dispatch.md) before your + handlers start doing anything slower than a quick reply. +- [Handle slash commands](../how-to/slash-commands.md) and + [interactive components](../how-to/interactive-components.md). +- Read the [architecture explanation](../explanation.md) to understand the + model behind what you just built. + +The example's own [README](../../examples/slack-hello-world/README.md) repeats +the Slack app setup with more detail (including Tailscale Funnel specifics) +if you need to revisit it later. diff --git a/go.work.sum b/go.work.sum index 3a8485d..95d07a3 100644 --- a/go.work.sum +++ b/go.work.sum @@ -8,12 +8,14 @@ github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZs github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os= +github.com/moby/sys/mount v0.3.5/go.mod h1:WUQDO+/uCiCIkIztx8SrwIDVn2dtMFRBebRhpDFT71M= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= github.com/nats-io/nats.go v1.39.1/go.mod h1:MgRb8oOdigA6cYpEPhXJuRVH6UE/V4jblJ2jQ27IXYM= @@ -35,6 +37,7 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=