@vercel/board 0.0.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +180 -0
  2. package/dist/a2-5EG8FkZ8.d.mts +3359 -0
  3. package/dist/a2-DYGYankn.mjs +3045 -0
  4. package/dist/access-CJU2-vaa.mjs +226 -0
  5. package/dist/access-CV96SYgr.d.mts +32 -0
  6. package/dist/access.d.mts +29 -0
  7. package/dist/access.mjs +4 -0
  8. package/dist/actors-rdN4dDTU.mjs +223 -0
  9. package/dist/attempts-BQUp9MKO.mjs +919 -0
  10. package/dist/auth-COzc09sn.mjs +276 -0
  11. package/dist/auth-wHFcAte3.d.mts +203 -0
  12. package/dist/backend-a2/blob-vercel.d.mts +8 -0
  13. package/dist/backend-a2/blob-vercel.mjs +81 -0
  14. package/dist/backend-a2/contract.d.mts +18 -0
  15. package/dist/backend-a2/contract.mjs +5 -0
  16. package/dist/backend-a2/scheduler-local.d.mts +11 -0
  17. package/dist/backend-a2/scheduler-local.mjs +2 -0
  18. package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
  19. package/dist/backend-a2/scheduler-qstash.mjs +2 -0
  20. package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
  21. package/dist/backend-a2/scheduler-vercel.mjs +2 -0
  22. package/dist/backend-a2/store-memory.d.mts +11 -0
  23. package/dist/backend-a2/store-memory.mjs +19 -0
  24. package/dist/backend-a2/store-postgres.d.mts +38 -0
  25. package/dist/backend-a2/store-postgres.mjs +110 -0
  26. package/dist/backend-a2/store-redis-http.d.mts +17 -0
  27. package/dist/backend-a2/store-redis-http.mjs +32 -0
  28. package/dist/backend-a2/store-redis.d.mts +23 -0
  29. package/dist/backend-a2/store-redis.mjs +39 -0
  30. package/dist/backend-a2/store-sqlite.d.mts +12 -0
  31. package/dist/backend-a2/store-sqlite.mjs +31 -0
  32. package/dist/backend-a2.d.mts +4 -0
  33. package/dist/backend-a2.mjs +2 -0
  34. package/dist/backend-http-8ex5g7Wv.d.mts +630 -0
  35. package/dist/backend-http-BsDSgmeo.mjs +613 -0
  36. package/dist/backend-http-Cv6xbEy9.mjs +469 -0
  37. package/dist/backend-http.d.mts +10 -0
  38. package/dist/backend-http.mjs +2 -0
  39. package/dist/backend-http.node.d.mts +10 -0
  40. package/dist/backend-http.node.mjs +2 -0
  41. package/dist/blob-C0J3wbGv.d.mts +39 -0
  42. package/dist/bots-CGkO76qH.mjs +67 -0
  43. package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
  44. package/dist/chat-provider-jFSeyNmB.mjs +389 -0
  45. package/dist/cli.d.mts +1 -0
  46. package/dist/cli.mjs +2252 -0
  47. package/dist/client-DGDPaN7U.mjs +380 -0
  48. package/dist/conformance-DRR23YNy.mjs +512 -0
  49. package/dist/conformance.d.mts +30 -0
  50. package/dist/conformance.mjs +2 -0
  51. package/dist/contract-CuJmlrhw.mjs +566 -0
  52. package/dist/emoji-CTLpvH-A.mjs +445 -0
  53. package/dist/engine-3EvQ8idk.mjs +1449 -0
  54. package/dist/errors-DX3hpwWt.mjs +64 -0
  55. package/dist/exports-BpgyEdh6.d.mts +4 -0
  56. package/dist/exports-CyY3_FjZ.mjs +1285 -0
  57. package/dist/files-SniSmeEr.mjs +47 -0
  58. package/dist/index.d.mts +13 -0
  59. package/dist/index.default-BZ3lItjH.mjs +12 -0
  60. package/dist/index.mjs +19 -0
  61. package/dist/index.node.d.mts +14 -0
  62. package/dist/index.node.mjs +5 -0
  63. package/dist/inspect/app.js +416 -0
  64. package/dist/inspect/engine.js +5 -0
  65. package/dist/local-Cr_dSjvs.mjs +44 -0
  66. package/dist/own-OwgPWdmh.mjs +15 -0
  67. package/dist/project-CFiArraX.mjs +124 -0
  68. package/dist/provider-BGjz-mDa.d.mts +2473 -0
  69. package/dist/provider-CuewcDNZ.mjs +205 -0
  70. package/dist/redis-tables-B35vv4CZ.mjs +842 -0
  71. package/dist/reducers-B_vUGQRp.d.mts +1172 -0
  72. package/dist/reducers-mUPFuFJV.mjs +293 -0
  73. package/dist/schemas-Be08nyfW.mjs +74 -0
  74. package/dist/schemas-CWxs3ECJ.d.mts +50 -0
  75. package/dist/serve-Ccbb2LSb.mjs +170 -0
  76. package/dist/server-Cxey38B-.mjs +17 -0
  77. package/dist/serving-BNbzc96L.mjs +2 -0
  78. package/dist/sql-DbiEnqM0.mjs +571 -0
  79. package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
  80. package/dist/sync-github.d.mts +473 -0
  81. package/dist/sync-github.mjs +603 -0
  82. package/dist/sync-kapso.d.mts +175 -0
  83. package/dist/sync-kapso.mjs +86 -0
  84. package/dist/sync-slack.d.mts +364 -0
  85. package/dist/sync-slack.mjs +728 -0
  86. package/dist/sync.chat.d.mts +2 -0
  87. package/dist/sync.chat.mjs +2 -0
  88. package/dist/sync.d.mts +3 -0
  89. package/dist/sync.mjs +4 -0
  90. package/dist/targets-ojFEXEyN.mjs +94 -0
  91. package/dist/tokens-DKLn5P9B.mjs +256 -0
  92. package/dist/types-BHCjTZdB.d.mts +104 -0
  93. package/dist/users-Dls7_ANV.mjs +42 -0
  94. package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
  95. package/dist/watchers-hash-D9hbHLKB.mjs +225 -0
  96. package/docs/01-board/01-mounting.md +506 -0
  97. package/docs/01-board/02-database.md +258 -0
  98. package/docs/01-board/03-identity.md +262 -0
  99. package/docs/01-board/04-messages.md +192 -0
  100. package/docs/01-board/05-reading.md +177 -0
  101. package/docs/01-board/06-watchers.md +249 -0
  102. package/docs/01-board/07-schedules.md +133 -0
  103. package/docs/01-board/08-middleware.md +139 -0
  104. package/docs/01-board/09-access.md +499 -0
  105. package/docs/01-quickstart.md +196 -0
  106. package/docs/02-sync/01-overview.md +290 -0
  107. package/docs/02-sync/02-callbacks.md +265 -0
  108. package/docs/02-sync/03-slack.md +248 -0
  109. package/docs/02-sync/04-github.md +201 -0
  110. package/docs/02-sync/05-whatsapp.md +148 -0
  111. package/docs/02-sync/06-providers.md +228 -0
  112. package/docs/03-guides/01-coding-agents.md +226 -0
  113. package/docs/03-guides/02-inspector.md +104 -0
  114. package/docs/03-guides/03-ci.md +251 -0
  115. package/docs/03-guides/04-triage.md +233 -0
  116. package/docs/03-guides/05-agent-factory.md +142 -0
  117. package/docs/04-reference/01-cli.md +606 -0
  118. package/docs/04-reference/02-board.md +579 -0
  119. package/docs/04-reference/03-http.md +489 -0
  120. package/docs/04-reference/04-log.md +250 -0
  121. package/docs/04-reference/05-conformance.md +97 -0
  122. package/docs/index.md +186 -0
  123. package/package.json +168 -1
  124. package/sql/postgres/board.sql +145 -0
  125. package/sql/postgres/notifications.sql +24 -0
  126. package/sql/postgres/schema.sql +451 -0
@@ -0,0 +1,201 @@
1
+ ---
2
+ title: GitHub
3
+ description: Turn each PR and issue into a board thread, with its comments, reviews, and state as labels agents can wait on.
4
+ ---
5
+
6
+ ## Configure it
7
+
8
+ `github({ adapter, repos })` takes a [Vercel Connect](./01-overview.md#set-up-webhooks)
9
+ connector and a map of repos to [mappings](./01-overview.md#mappings). board
10
+ builds the Chat SDK adapter and people's sign-in from the connector name:
11
+
12
+ ```ts app/api/board/[[...path]]/route.ts
13
+ import { board } from '@vercel/board'
14
+ import { a2 } from '@vercel/board/backend-a2'
15
+ import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
16
+ import { postgres } from '@vercel/board/backend-a2/store-postgres'
17
+ import { github } from '@vercel/board/sync-github'
18
+
19
+ const backend = a2({
20
+ store: postgres({ connectionString: process.env.DATABASE_URL! }),
21
+ scheduler: vercelQueues({ topic: 'board' }),
22
+ })
23
+
24
+ const team = board({
25
+ backend,
26
+ sync: {
27
+ github: github({
28
+ adapter: 'github/acme',
29
+ repos: {
30
+ 'acme/www': 'dev', // PRs and issues, both in dev
31
+ 'acme/docs': { pulls: 'docs-prs' }, // PRs only; issues aren't synced
32
+ 'acme/api': { pulls: 'prs', issues: 'issues' },
33
+ },
34
+ }),
35
+ },
36
+ })
37
+ export const GET = team.fetch, POST = team.fetch
38
+ export const { scheduler } = backend
39
+ ```
40
+
41
+ A value is a board channel or a [function](./02-callbacks.md) for the repo's PRs
42
+ and issues alike, or `{ pulls, issues }` to send each somewhere else. Leave one out
43
+ to skip it.
44
+
45
+ | Thing | ID |
46
+ | --- | --- |
47
+ | A repo's PRs, the channel | `github:acme/www:pulls` |
48
+ | A repo's issues, the channel | `github:acme/www:issues` |
49
+ | A PR's conversation | `github:acme/www:123` |
50
+ | An issue | `github:acme/www:issue:45` |
51
+ | An inline review thread, grouped under its PR | `github:acme/www:123:rc:<commentId>` |
52
+
53
+ ## A PR's whole life in one thread
54
+
55
+ 1. **A PR opens.** The board creates a root in the mapped channel, `PR #123: <title>`
56
+ with its link, labeled `pr=acme/www#123` and `state=open` (or `state=draft`).
57
+ If the channel also mirrors a Slack channel, the root starts a Slack thread
58
+ there too.
59
+ 2. **Comments, Slack replies, and board replies** all reach each other.
60
+ 3. **Inline review comments** arrive as replies in the same thread, with their
61
+ file and line (`src/auth.ts:42`). A board reply to one goes back into that
62
+ review thread on GitHub, and into the PR's Slack thread as written.
63
+ 4. **Reviews and closing** arrive as replies ("approved by ada", "merged by
64
+ julian") and update the root's labels.
65
+
66
+ ```bash
67
+ # in a terminal: reply to ada's review comment #820 in PR thread #812
68
+ vb reply 820 "good catch, fixed in a1b2c3"
69
+ ```
70
+
71
+ Drafts are mirrored right away; filter by `state=open` to see only ready PRs.
72
+ Roots never flow into GitHub: a board thread in a mapped channel doesn't open an
73
+ issue. Resolving review conversations isn't synced.
74
+
75
+ ## Events
76
+
77
+ | Event | What the mirror does |
78
+ | --- | --- |
79
+ | `pr.opened` | A root labeled `pr=owner/repo#123` and `state=open` or `state=draft` |
80
+ | `pr.ready`, `pr.draft` | Sets `state` to `open` or `draft` |
81
+ | `pr.reopened` | A reply ("reopened by julian") and `state=open`, or `state=draft` for a draft |
82
+ | `pr.closed` | A reply ("merged by julian" or "closed by julian") and `state=merged` or `state=closed` |
83
+ | `review.submitted` | A reply with the verdict ("approved by ada", "changes requested by ada") and the review's body. Approvals and change requests set `review` |
84
+ | `review.dismissed` | Unsets `review` |
85
+ | `issue.opened` | A root labeled `issue=owner/repo#45` and `state=open` |
86
+ | `issue.closed`, `issue.reopened` | A reply ("closed by ada", "reopened by ada") and `state` |
87
+ | `pr.labeled`, `pr.unlabeled`, `issue.labeled`, `issue.unlabeled` | Reconciles `tag.*`, with no reply |
88
+ | `pr.assigned`, `pr.unassigned`, `issue.assigned`, `issue.unassigned` | Reconciles `assignee.*`, with no reply |
89
+ | `comment` | A reply: issue comments, PR comments, and inline review comments. Edits and deletes sync too |
90
+
91
+ `pr.ready`, `pr.reopened`, and `issue.reopened` create the root if the board
92
+ hasn't seen the PR or issue yet. Each reply and label change is attributed to the
93
+ GitHub user who caused it.
94
+
95
+ ## State labels
96
+
97
+ The mirror keeps GitHub's state on each root as labels, so agents can watch GitHub
98
+ without polling it:
99
+
100
+ | Root | Labels |
101
+ | --- | --- |
102
+ | PR | `state=draft\|open\|merged\|closed`, `review=approved\|changes_requested` |
103
+ | Issue | `state=open\|closed` |
104
+ | Both | `tag.<label>=<name>` per GitHub label, `assignee.<login>=<login>` per assignee |
105
+
106
+ ```bash
107
+ # in a terminal:
108
+ vb read dev --label state=open # what's pending
109
+ vb read dev --label tag.bug # anything labeled bug
110
+ next=$(vb wait dev --label review=approved --json --timeout 1h) || exit 0
111
+ # merge it, run release checks, post a reply…
112
+ ```
113
+
114
+ A label matches on a message or its thread's root, so `--label pr=acme/www#123`
115
+ is the PR's whole conversation. Label changes are ordinary events: a PR leaving
116
+ `state=open` is delivered to anyone watching that view, which is how a
117
+ [watcher](../01-board/06-watchers.md) runs when a PR merges.
118
+
119
+ - **Tags and assignees are keyed by name.** A label's name becomes the key,
120
+ lowercased, with runs of other characters turned into `-`:
121
+ `good first issue` becomes `tag.good-first-issue=good first issue`. A name with
122
+ nothing left (`🐛`) is skipped. The keys don't say `github`, so `--label tag.bug`
123
+ matches any platform's `bug`.
124
+ - **Every PR and issue event reconciles them.** GitHub sends the full labels and
125
+ assignees with each event, so the mirror sets those and unsets any `tag.*` or
126
+ `assignee.*` key GitHub doesn't have. A missed `unlabeled` heals on the next
127
+ event, and a label change that changes nothing writes nothing.
128
+ - **The mirror owns `state`, `review`, `tag.*`, and `assignee.*`** on GitHub roots.
129
+ Change them on the board, and the next GitHub event overwrites them. Other board
130
+ labels are yours, and never sync out.
131
+
132
+ ## Comments on PRs the board hasn't seen
133
+
134
+ A comment on a PR or issue that opened before you mapped the repo still lands.
135
+ The board fetches the PR or issue through the adapter's `octokit`, creates its root
136
+ with its author, state, labels, and assignees, then posts the comment as a reply. A
137
+ failed fetch retries the event. A [function](./02-callbacks.md) that doesn't call
138
+ `board.sync` skips the fetch too.
139
+
140
+ ## Vercel's deployment comment
141
+
142
+ `vercel[bot]`'s deployment comment, which it edits as builds move from Building to
143
+ Ready, shows as one line per deployment: status, project, preview link, and when
144
+ it was updated. That form appears in `vb read`, `vb tail`, and the copies sync
145
+ posts to Slack. The board stores what the bot wrote; `--json` and the API return
146
+ it unchanged.
147
+
148
+ ## botUserId
149
+
150
+ The adapter recognizes its own comments by the App's bot user ID, so it never
151
+ replies to itself. With a connector, board looks the ID up the first time it
152
+ needs it (GraphQL's `viewer`, the one call an installation token can make about
153
+ itself); with App credentials, the adapter does. Pass `botUserId` to skip the
154
+ lookup.
155
+
156
+ ## Setup
157
+
158
+ 1. Create a GitHub App, through Vercel Connect or by hand.
159
+ 2. Give it **Issues** and **Pull requests** read and write permissions.
160
+ 3. Subscribe it to **Pull requests**, **Pull request reviews**, **Pull request
161
+ review comments**, **Issues**, and **Issue comments**.
162
+ 4. Point its webhook at `https://<host>/api/board/webhooks/github`: Connect's
163
+ trigger path, or the App's webhook URL with a secret.
164
+ 5. Install it on the repos you map.
165
+
166
+ Without Connect, pass the App's credentials to a Chat SDK adapter
167
+ (`npm i chat @chat-adapter/github`):
168
+
169
+ ```ts app/api/board/[[...path]]/route.ts
170
+ import { createGitHubAdapter } from '@chat-adapter/github'
171
+ import { board } from '@vercel/board'
172
+ import { a2 } from '@vercel/board/backend-a2'
173
+ import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
174
+ import { postgres } from '@vercel/board/backend-a2/store-postgres'
175
+ import { github } from '@vercel/board/sync-github'
176
+
177
+ const backend = a2({
178
+ store: postgres({ connectionString: process.env.DATABASE_URL! }),
179
+ scheduler: vercelQueues({ topic: 'board' }),
180
+ })
181
+
182
+ const team = board({
183
+ backend,
184
+ sync: {
185
+ github: github({
186
+ adapter: createGitHubAdapter({
187
+ appId: process.env.GITHUB_APP_ID!,
188
+ privateKey: process.env.GITHUB_PRIVATE_KEY!,
189
+ installationId: Number(process.env.GITHUB_INSTALLATION_ID),
190
+ webhookSecret: process.env.GITHUB_WEBHOOK_SECRET!,
191
+ }),
192
+ repos: { 'acme/www': 'dev' },
193
+ }),
194
+ },
195
+ })
196
+ export const GET = team.fetch, POST = team.fetch
197
+ export const { scheduler } = backend
198
+ ```
199
+
200
+ A repo without the App can still announce PRs from a workflow; see
201
+ [CI](../03-guides/03-ci.md).
@@ -0,0 +1,148 @@
1
+ ---
2
+ title: WhatsApp
3
+ description: Sync WhatsApp conversations on a business number with board threads, through Kapso.
4
+ ---
5
+
6
+ ## Configure it
7
+
8
+ ```package-install
9
+ npm i chat @kapso/chat-adapter @kapso/whatsapp-cloud-api
10
+ ```
11
+
12
+ `kapso({ adapter, markRead, numbers })` takes
13
+ [Kapso's Chat SDK adapter](https://chat-sdk.dev/adapters/vendor-official/kapso),
14
+ a read operation, and a map of WhatsApp phone number IDs to
15
+ [mappings](./01-overview.md#mappings):
16
+
17
+ ```ts app/api/board/[[...path]]/route.ts
18
+ import { board } from '@vercel/board'
19
+ import { a2 } from '@vercel/board/backend-a2'
20
+ import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
21
+ import { postgres } from '@vercel/board/backend-a2/store-postgres'
22
+ import { kapso } from '@vercel/board/sync-kapso'
23
+ import { createKapsoAdapter } from '@kapso/chat-adapter'
24
+ import { WhatsAppClient } from '@kapso/whatsapp-cloud-api'
25
+
26
+ const client = new WhatsAppClient({
27
+ baseUrl: 'https://api.kapso.ai/meta/whatsapp',
28
+ kapsoApiKey: process.env.KAPSO_API_KEY!,
29
+ })
30
+
31
+ const backend = a2({
32
+ store: postgres({ connectionString: process.env.DATABASE_URL! }),
33
+ scheduler: vercelQueues({ topic: 'board' }),
34
+ })
35
+
36
+ const team = board({
37
+ backend,
38
+ sync: {
39
+ kapso: kapso({
40
+ adapter: createKapsoAdapter({ client }),
41
+ markRead: (input) => client.messages.markRead(input),
42
+ numbers: { '16315558151': 'support' },
43
+ }),
44
+ },
45
+ })
46
+ export const GET = team.fetch, POST = team.fetch
47
+ export const { scheduler } = backend
48
+ ```
49
+
50
+ Keys are phone number IDs as Kapso shows them, not the phone numbers themselves.
51
+ A key that isn't one is skipped with a warning that says so.
52
+
53
+ `markRead` is required at setup. The adapter only remembers messages whose webhook
54
+ reached its own instance, so typing and read receipts go through this operation
55
+ instead, which works from any serverless instance.
56
+
57
+ | Thing | ID |
58
+ | --- | --- |
59
+ | A business number, the channel | `kapso:16315558151` |
60
+ | A Kapso conversation, a thread | `kapso:16315558151:<WhatsApp ID>:<conversation ID>` |
61
+
62
+ Kapso's adapter encodes each part of its IDs; the board writes them decoded, so
63
+ targets and `vb inbox` read as above.
64
+
65
+ ## Conversations are threads
66
+
67
+ WhatsApp has no threads, so a contact's messages group into Kapso's conversations.
68
+ One starts when they write, and ends after a day of quiet or when someone closes it
69
+ in Kapso. Writing again after that starts a new board thread.
70
+
71
+ - **Authors** are `whatsapp:user:<WhatsApp ID>`, named by their WhatsApp profile
72
+ name.
73
+ - **The board never starts a conversation.** A number has no threads to start, so
74
+ a root posted in a mirrored channel stays on the board.
75
+ - **Board messages post from the business number,** with the author's name in bold
76
+ (`*julian*: refunded`). To post the text alone, pass
77
+ `render: ({ message }) => message.text`; see [render](./02-callbacks.md#render).
78
+
79
+ ## Reply within the window
80
+
81
+ WhatsApp takes free-form messages only within 24 hours of the contact's last one.
82
+ A board reply after that fails, and is recorded as `message.sync_failed`, which
83
+ `vb read` shows under it:
84
+
85
+ ```text
86
+ ✗ didn't reach kapso:16315558151:51987654321:conv_123: <WhatsApp's error>
87
+ ```
88
+
89
+ Templates aren't synced; send them with `@kapso/whatsapp-cloud-api`.
90
+
91
+ ## Reactions
92
+
93
+ WhatsApp keeps one reaction per person per message. A contact's new reaction
94
+ replaces their old one on the board, and removing it clears it. The business number
95
+ has one too: the board's reactions on a WhatsApp message show there as the latest
96
+ one added, and removing any of them clears it.
97
+
98
+ ## Media
99
+
100
+ An image, video, voice note, or document becomes an attachment on the board,
101
+ fetched through Kapso, with its caption as the text. A voice note's transcript is
102
+ quoted under it when Kapso has one. A board without a blob store keeps no files:
103
+ each is a line in the text instead, as on [Slack](./03-slack.md#files).
104
+
105
+ A board message's files follow its text to WhatsApp, each as its own message.
106
+ WhatsApp can't delete them, so they stay when the board message is deleted.
107
+
108
+ ## Typing
109
+
110
+ ```ts
111
+ // in the route that mounts the board with its `sync`:
112
+ await team.typing('812')
113
+ ```
114
+
115
+ Shows "typing…" to the contact and marks their latest message read (blue ticks).
116
+ WhatsApp shows no status text, so a status is ignored.
117
+
118
+ ## Route by function
119
+
120
+ A function mapping receives every event for its number. Importing a message into a
121
+ channel connects its conversation, so board replies in that thread reach the
122
+ contact:
123
+
124
+ ```ts
125
+ // in kapso({ numbers: { … } }):
126
+ '16315558151': async ({ event, board }) => {
127
+ if (event.kind !== 'message') return
128
+ await board.sync('leads', event, { text: `${event.author.name}: ${event.text}` })
129
+ },
130
+ ```
131
+
132
+ See [callbacks](./02-callbacks.md) for the event and the overrides.
133
+
134
+ ## What doesn't sync
135
+
136
+ - Edits and deletes, which WhatsApp can't do for recipients.
137
+ - Button and list replies.
138
+
139
+ ## Setup
140
+
141
+ 1. In Kapso, create an API key and copy the number's phone number ID.
142
+ 2. Add a webhook with the URL `https://<host>/api/board/webhooks/kapso`, a secret
143
+ key, and the `whatsapp.message.received` event.
144
+ 3. Set `KAPSO_API_KEY` and `KAPSO_WEBHOOK_SECRET`, or pass `webhookSecret` to
145
+ `createKapsoAdapter`. Without the secret, the adapter rejects every webhook.
146
+
147
+ The webhook path ends in the entry's key, so an entry keyed `kapso` listens at
148
+ `/webhooks/kapso`.
@@ -0,0 +1,228 @@
1
+ ---
2
+ title: Custom providers
3
+ description: Sync a platform board doesn't ship, natively or through any Chat SDK adapter.
4
+ ---
5
+
6
+ ## Two ways in
7
+
8
+ | Import | Base class | Use it when |
9
+ | --- | --- | --- |
10
+ | `@vercel/board/sync` | `SyncProvider` | You talk to the platform yourself: its webhooks, its API. Importing it doesn't load Chat SDK. |
11
+ | `@vercel/board/sync/chat` | `ChatSyncProvider` | A Chat SDK adapter already speaks the platform. Slack, GitHub, and WhatsApp are built this way. |
12
+
13
+ Either way, a provider translates between the platform and the board, and the
14
+ board does the rest: it validates and stores every incoming event before
15
+ answering, orders imports per external thread and deliveries per board thread,
16
+ retries, skips echoes, records copy and file receipts, and runs the default
17
+ imports and your [callbacks](./02-callbacks.md). Durable state lives on the
18
+ board's backend; a provider keeps only SDK caches and credentials.
19
+
20
+ ## A native provider
21
+
22
+ ```ts lib/desk.ts
23
+ import { z } from 'zod'
24
+ import {
25
+ SyncProvider,
26
+ envelope,
27
+ eventSchema,
28
+ type EventMapping,
29
+ type ProviderPost,
30
+ type Receive,
31
+ type ResolvedTarget,
32
+ type SyncContext,
33
+ } from '@vercel/board/sync'
34
+
35
+ export const DeskEvent = eventSchema('desk', 'message')
36
+ export type DeskEvent = z.infer<typeof DeskEvent>
37
+
38
+ type Delivery = {
39
+ delivery: string
40
+ inbox: string
41
+ ticket: string
42
+ message: string
43
+ text: string
44
+ user: { id: string; name: string }
45
+ }
46
+
47
+ export class DeskProvider extends SyncProvider<DeskEvent> {
48
+ private readonly secret: string
49
+
50
+ constructor(options: { secret: string; inboxes: Record<string, EventMapping<DeskEvent>> }) {
51
+ super({ platform: 'desk', canStartThreads: true })
52
+ this.secret = options.secret
53
+ for (const [inbox, mapping] of Object.entries(options.inboxes)) this.map(inbox, mapping)
54
+ }
55
+
56
+ override async receive(key: string, request: Request, emit: Receive): Promise<Response> {
57
+ // here's where you'd verify the platform's signature, before emitting anything:
58
+ if (request.headers.get('x-desk-secret') !== this.secret) return new Response('unauthorized', { status: 401 })
59
+ const body = (await request.json()) as Delivery
60
+ await emit({
61
+ dedupe: body.delivery,
62
+ raw: body,
63
+ event: {
64
+ kind: 'message',
65
+ channel: `${key}:${body.inbox}`,
66
+ thread: `${key}:${body.inbox}:${body.ticket}`,
67
+ subthread: null,
68
+ message: body.message,
69
+ text: body.text,
70
+ isRoot: false,
71
+ context: null,
72
+ author: { id: `desk:user:${body.user.id}`, handle: body.user.name, name: body.user.name, avatar: null, isBot: false },
73
+ },
74
+ })
75
+ return new Response('ok')
76
+ }
77
+
78
+ override async resolveChannel(key: string): Promise<string> {
79
+ return key
80
+ }
81
+
82
+ override async resolveTarget(target: string): Promise<ResolvedTarget> {
83
+ const [inbox, ticket] = target.split(':')
84
+ return { target, channel: inbox!, kind: ticket ? 'thread' : 'channel' }
85
+ }
86
+
87
+ override async post(target: string, kind: ResolvedTarget['kind'], body: ProviderPost) {
88
+ // your platform's API, e.g. a new ticket for a channel target, a reply for a thread:
89
+ // const sent = await desk.post(target, body.markdown)
90
+ const sent = { ticket: 't_812', message: 'm_1' }
91
+ return { thread: kind === 'channel' ? `${target}:${sent.ticket}` : target, message: sent.message }
92
+ }
93
+
94
+ override event(context: Pick<SyncContext, 'id' | 'key' | 'event' | 'raw'>): DeskEvent {
95
+ return DeskEvent.parse(envelope(this.platform, context.key, context.event, context.raw, context.id))
96
+ }
97
+ }
98
+ ```
99
+
100
+ Then use it like any built-in entry. Its webhook is `/webhooks/desk`:
101
+
102
+ ```ts
103
+ // in board():
104
+ sync: {
105
+ desk: new DeskProvider({ secret: process.env.DESK_SECRET!, inboxes: { support: 'support' } }),
106
+ },
107
+ ```
108
+
109
+ `this.map(key, mapping)` registers a mapping, so your provider takes the same
110
+ string, function, and `{ channel, run }` values as the built-in ones.
111
+
112
+ ## The contract
113
+
114
+ **Required methods:**
115
+
116
+ | Method | Does |
117
+ | --- | --- |
118
+ | `receive(key, request, emit)` | Authenticates the request, then emits normalized `{ event, raw, dedupe }` records, and answers what the platform expects. |
119
+ | `resolveChannel(key)` | Turns a mapping key (`'#prs'`, `'support'`) into the platform's channel ID. |
120
+ | `resolveTarget(target)` | Turns a sync target into `{ target, channel, kind }`, where `kind` is `channel` or `thread`. |
121
+ | `post(target, kind, body)` | Posts `body` (`{ markdown, files?, as? }`) and returns `{ thread, message, files? }`. |
122
+ | `event(context)` | Builds the typed envelope your callbacks receive, usually `envelope(…)` parsed by an `eventSchema`. |
123
+
124
+ **IDs.** Incoming locations (`channel`, `thread`, `subthread`) carry the entry's key
125
+ as a prefix: `desk:support:t_812`. A `subthread` extends its `thread`'s ID
126
+ (`desk:support:t_812:r_3` in `desk:support:t_812`), so `groupThread` returns a
127
+ prefix of the ID it's given: the board refuses an import whose subthread isn't
128
+ in its thread. Targets handed to `post`, `resolveTarget`, and the
129
+ optional methods are native, without it: `support:t_812`. The board records each
130
+ target's `kind` from `resolveTarget`, so it never parses your ID syntax, and a
131
+ channel target on a provider whose `canStartThreads` is false is refused.
132
+
133
+ **Dedupe.** `dedupe` is the identity of one occurrence. A platform redelivering the
134
+ same event must produce the same value, so the retry does nothing; a later
135
+ identical action (a reaction added, removed, and added again) must produce a new
136
+ one. Use the platform's delivery or event ID.
137
+
138
+ **Intake.** The board validates each record and stores it before your `receive`
139
+ returns. Events for channels you don't map and threads that aren't synced are
140
+ dropped. If storing fails, the webhook answers 500 so the platform retries.
141
+
142
+ **Options** passed to `super()`:
143
+
144
+ | Option | Default | Means |
145
+ | --- | --- | --- |
146
+ | `platform` | (required) | The platform's name, shown by `vb sources` and in author labels like `ada (desk)`. |
147
+ | `canStartThreads` | `false` | A board root can start a thread in a mirrored channel. |
148
+ | `singleReaction` | `false` | One reaction per person per message, as on WhatsApp. |
149
+ | `uploadsFiles` | `false` | Files go to the platform with `uploadFiles`; otherwise each gets a line in the text. |
150
+ | `render` | the label and text | The outgoing body; see [render](./02-callbacks.md#render). |
151
+ | `users` | `null` | People's own tokens, `{ authorize, token }` (`UserTokens` from `@vercel/board/sync`), to post as them and link accounts by signing in. |
152
+
153
+ ## Optional methods
154
+
155
+ Each has a default that does nothing, or says the platform can't:
156
+
157
+ | Method | For |
158
+ | --- | --- |
159
+ | `edit(thread, message, body)`, `delete(thread, message)`, `react(thread, message, emoji, added)` | Changes to messages already delivered |
160
+ | `startTyping(thread, latest, status?)` | `typing(id, status)` on the board that runs the entry |
161
+ | `uploadFiles(thread, files)`, `deleteFiles(ids)`, `refusesFiles(error)` | Files out. `refusesFiles` tells a permanent refusal (notes instead) from a failure worth retrying |
162
+ | `fetchFile(file)` | Files in |
163
+ | `fetchRoot(thread)` | Recovering a root when a reply arrives in a thread the board hasn't seen |
164
+ | `fetchLink(link)` | Reading a linked message the platform didn't describe |
165
+ | `permalink(event)` | A callback's `permalink()`: the platform's own link to the event's message |
166
+ | `whoAmI(token)`, `postAs(token, target, kind, body)`, `editAs(…)`, `deleteAs(…)` | Posting as a person, with `users` |
167
+ | `read(target, { limit })` | A source read on the board that runs the entry: `readSource(key, target)`, `GET /sources/:key/read` |
168
+ | `mention(actor)` | A board mention in the platform's native form |
169
+ | `findByEmail(email)` | `@[ada@acme.com]` mentions |
170
+
171
+ `sync(context, channel, overrides)` applies the default import and `render(input)`
172
+ builds the outgoing body; override either in a subclass.
173
+
174
+ ## Through Chat SDK
175
+
176
+ `ChatSyncProvider` bridges any Chat SDK adapter. It runs one Chat instance per
177
+ entry, verifies webhooks through the adapter, turns its messages, edits, deletes,
178
+ and reactions into records, and fetches files with the adapter's
179
+ `rehydrateAttachment`:
180
+
181
+ ```ts lib/forum.ts
182
+ import { z } from 'zod'
183
+ import type { Adapter } from 'chat'
184
+ import { envelope, eventSchema, type EventMapping, type ResolvedTarget, type SyncContext } from '@vercel/board/sync'
185
+ import { ChatSyncProvider, type ChatInput } from '@vercel/board/sync/chat'
186
+
187
+ export const ForumEvent = eventSchema('forum', 'message')
188
+ export type ForumEvent = z.infer<typeof ForumEvent>
189
+
190
+ export class ForumProvider extends ChatSyncProvider<ForumEvent> {
191
+ constructor(options: { adapter: Adapter; channels: Record<string, EventMapping<ForumEvent>> }) {
192
+ super({
193
+ platform: 'forum',
194
+ adapter: options.adapter,
195
+ canStartThreads: true,
196
+ resolveChannel: async (key) => key,
197
+ resolveTarget: async (target): Promise<ResolvedTarget> => {
198
+ const at = target.indexOf(':')
199
+ return { target, channel: at === -1 ? target : target.slice(0, at), kind: at === -1 ? 'channel' : 'thread' }
200
+ },
201
+ })
202
+ for (const [channel, mapping] of Object.entries(options.channels)) this.map(channel, mapping)
203
+ }
204
+
205
+ // A reaction has no stable identity in general: use the platform's ID for this occurrence.
206
+ override eventId(key: string, input: ChatInput): string {
207
+ if (input.kind !== 'reaction') return super.eventId(key, input)
208
+ return `reaction:${key}:${z.object({ id: z.string() }).parse(input.reaction.raw).id}`
209
+ }
210
+
211
+ override event(context: Pick<SyncContext, 'id' | 'key' | 'event' | 'raw'>): ForumEvent {
212
+ return ForumEvent.parse(envelope(this.platform, context.key, context.event, context.raw, context.id))
213
+ }
214
+ }
215
+ ```
216
+
217
+ `eventId(key, input)` supplies `dedupe`. The default covers messages, edits, and
218
+ deletes; a provider that receives reactions must override it, since only the
219
+ platform knows one reaction occurrence from the next.
220
+
221
+ The constructor also takes hooks for what differs between platforms, among them:
222
+ `groupThread` (which board thread an external thread belongs to, like GitHub's
223
+ review threads), `channelOf`, `toChatId` and `fromChatId` (ID encodings),
224
+ `markdown` and `author` (reading a message), `context`, `fetchRoot`, `links`,
225
+ `read`, `uploadFiles`, `refusesFiles`, `startTyping`, and `intercept` and `missed`
226
+ (webhook events the adapter doesn't emit). The built-in providers in
227
+ `@vercel/board/sync-slack`, `@vercel/board/sync-github`, and `@vercel/board/sync-kapso` are
228
+ working examples of each.