@vercel/board 0.16.0 → 0.18.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 (130) hide show
  1. package/README.md +27 -27
  2. package/dist/{a2-C41hD8SF.mjs → a2-ChRLRupa.mjs} +969 -25
  3. package/dist/access.d.mts +1 -1
  4. package/dist/access.mjs +3 -3
  5. package/dist/{actors-DjMeZpnU.mjs → actors-BOamkEuz.mjs} +3 -73
  6. package/dist/attempts-CLG840Oj.mjs +20 -0
  7. package/dist/{auth-COzc09sn.mjs → auth-Drg9FiC2.mjs} +225 -2
  8. package/dist/backend-B-SJ7p6p.d.mts +680 -0
  9. package/dist/backend-a2/contract.d.mts +873 -2
  10. package/dist/backend-a2/contract.mjs +2 -2
  11. package/dist/backend-a2/scheduler-local.d.mts +1 -1
  12. package/dist/backend-a2/scheduler-local.mjs +1 -1
  13. package/dist/backend-a2/scheduler-qstash.d.mts +78 -1
  14. package/dist/backend-a2/scheduler-qstash.mjs +521 -1
  15. package/dist/backend-a2/scheduler-vercel.d.mts +37 -1
  16. package/dist/backend-a2/scheduler-vercel.mjs +203 -1
  17. package/dist/backend-a2/store-memory.d.mts +2 -2
  18. package/dist/backend-a2/store-memory.mjs +3 -3
  19. package/dist/backend-a2/store-postgres.d.mts +1 -1
  20. package/dist/backend-a2/store-postgres.mjs +2850 -2
  21. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  22. package/dist/backend-a2/store-redis-http.mjs +490 -2
  23. package/dist/backend-a2/store-redis.d.mts +10 -2
  24. package/dist/backend-a2/store-redis.mjs +51 -2
  25. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  26. package/dist/backend-a2/store-sqlite.mjs +3 -3
  27. package/dist/backend-a2.d.mts +2706 -3
  28. package/dist/backend-a2.mjs +1 -1
  29. package/dist/{backend-http-Bid7RIpC.d.mts → backend-http-7cNb8nJI.d.mts} +10 -3
  30. package/dist/{backend-http-0roEmjl0.mjs → backend-http-CMlL3yq3.mjs} +2 -2
  31. package/dist/{backend-http-BVRno-ca.mjs → backend-http-Ddzd5kTS.mjs} +32 -3
  32. package/dist/backend-http.d.mts +1 -1
  33. package/dist/backend-http.mjs +1 -1
  34. package/dist/backend-http.node.d.mts +1 -1
  35. package/dist/backend-http.node.mjs +1 -1
  36. package/dist/{chat-provider-XCnQlxq6.d.mts → chat-provider-DNJQyajS.d.mts} +16 -4
  37. package/dist/{chat-provider-jFSeyNmB.mjs → chat-provider-YHy-WbrB.mjs} +15 -9
  38. package/dist/cli.mjs +30 -10
  39. package/dist/{client-DGDPaN7U.mjs → client-BA-J7usH.mjs} +10 -2
  40. package/dist/{conformance-DRR23YNy.mjs → conformance-CT5EeHeC.mjs} +5 -2
  41. package/dist/conformance.d.mts +1 -1
  42. package/dist/conformance.mjs +1 -1
  43. package/dist/{engine-Bir5s8ts.mjs → engine-D0oBUBEK.mjs} +249 -163
  44. package/dist/events-CewD4kw7.mjs +75 -0
  45. package/dist/{exports-Dvc1o52I.mjs → exports-CgjfMrQ-.mjs} +35 -32
  46. package/dist/exports-ZsgjzEnM.d.mts +4 -0
  47. package/dist/external-BV_cUncs.mjs +151 -0
  48. package/dist/folds-CRYY14kc.d.mts +534 -0
  49. package/dist/{reducers-mUPFuFJV.mjs → folds-Dyjput8M.mjs} +6 -73
  50. package/dist/ids-C6AYo1bu.mjs +31 -0
  51. package/dist/index.d.mts +4 -4
  52. package/dist/{index.default-Bkg77Pxn.mjs → index.default-C5Cpz3sa.mjs} +1 -1
  53. package/dist/index.mjs +5 -5
  54. package/dist/index.node.d.mts +4 -4
  55. package/dist/index.node.mjs +3 -3
  56. package/dist/inspect/app.js +31 -31
  57. package/dist/inspect/engine.js +5 -5
  58. package/dist/internal-CvsrprnU-CRY89I-b.mjs +203 -0
  59. package/dist/{local-Cr_dSjvs.mjs → local-DhkrAqPP.mjs} +1 -1
  60. package/dist/mentions-CB6ShY4R.mjs +72 -0
  61. package/dist/{targets-yM5JBUgr.mjs → middleware-CqaCwucP.mjs} +1 -33
  62. package/dist/platform-B4TnJtWu-CwcTyKg2.mjs +32 -0
  63. package/dist/{project-CFiArraX.mjs → project-DMDST9kl.mjs} +1 -1
  64. package/dist/{provider-BGjz-mDa.d.mts → provider-B0b9Qxw4.d.mts} +170 -40
  65. package/dist/provider-B3DpYSDq.mjs +220 -0
  66. package/dist/redis-tables-DN9PfIDi.mjs +3897 -0
  67. package/dist/reducer-CYPHsSmd-BQIfozGi.d.mts +170 -0
  68. package/dist/reducer-CjmvZSAb-D_OSISmj.mjs +74 -0
  69. package/dist/{contract-CuJmlrhw.mjs → reducers-BFmIXilu.mjs} +260 -287
  70. package/dist/retryable-lazy-DZWmHpii-Bye-W8q7.mjs +19 -0
  71. package/dist/scheduler-task-X1SToDjP-BVoWuInI.mjs +69 -0
  72. package/dist/{serve-BsOOc-6_.mjs → serve-BGYw0uNL.mjs} +5 -5
  73. package/dist/{server-jwPY7_wi.mjs → server-DPxBSogx.mjs} +4 -4
  74. package/dist/server-DtFrWxi3-Boa6gdWO.mjs +3602 -0
  75. package/dist/server-qgez0Xny-D5mLfqFQ.d.mts +385 -0
  76. package/dist/{serving-BNbzc96L.mjs → serving-CNkgUPW1.mjs} +1 -1
  77. package/dist/session-clear-wHd5GCpZ-BnKSxReK.mjs +51 -0
  78. package/dist/{sql-DbiEnqM0.mjs → sql-U9WTxrwJ.mjs} +2 -2
  79. package/dist/{sqlite-db-Cbj03vmp.mjs → sqlite-db-Ds4majtT.mjs} +1 -1
  80. package/dist/store-D0MhBvg6-4Z2L0Kqd.d.mts +530 -0
  81. package/dist/store-N8PXxDAS-BWBgzBHp.mjs +5 -0
  82. package/dist/store-codec-DwrWlgQx-CYxGW0aD.mjs +61 -0
  83. package/dist/store-memory-Bas9Uq5C.mjs +872 -0
  84. package/dist/store-memory-ZLiZbH3R.mjs +2 -0
  85. package/dist/store-polling-BZt2Rf3D-BiEM6_HB.mjs +148 -0
  86. package/dist/store-presence-polling-JuAy2Ck2-CYpuK75X.mjs +109 -0
  87. package/dist/store-sqlite-BmwL3pUR.mjs +2 -0
  88. package/dist/store-sqlite-CxxPCcCT.mjs +1053 -0
  89. package/dist/stream-buffer-DrTyiIuo-BKy-vI14.mjs +53 -0
  90. package/dist/sync-github.d.mts +24 -17
  91. package/dist/sync-github.mjs +89 -22
  92. package/dist/sync-kapso.d.mts +15 -13
  93. package/dist/sync-kapso.mjs +17 -14
  94. package/dist/sync-linear.d.mts +219 -0
  95. package/dist/sync-linear.mjs +311 -0
  96. package/dist/sync-slack.d.mts +12 -17
  97. package/dist/sync-slack.mjs +54 -42
  98. package/dist/sync.chat.d.mts +1 -1
  99. package/dist/sync.chat.mjs +1 -1
  100. package/dist/sync.d.mts +3 -3
  101. package/dist/sync.mjs +5 -4
  102. package/dist/targets-C-wdR5wP.mjs +33 -0
  103. package/dist/{tokens-DKLn5P9B.mjs → tokens-DhuzKzAl.mjs} +1 -1
  104. package/dist/{types-BHCjTZdB.d.mts → types-8yq2VnKI.d.mts} +2 -2
  105. package/dist/{users-Dls7_ANV.mjs → users-yryHKFMo.mjs} +2 -2
  106. package/dist/{watch-spec-W0c7ypHJ.mjs → watchers-hash-RLtnSF-t.mjs} +233 -28
  107. package/docs/01-board/01-mounting.md +1 -1
  108. package/docs/02-sync/01-overview.md +72 -48
  109. package/docs/02-sync/02-functions.md +398 -0
  110. package/docs/02-sync/03-slack.md +30 -25
  111. package/docs/02-sync/04-github.md +62 -26
  112. package/docs/02-sync/05-whatsapp.md +23 -19
  113. package/docs/02-sync/06-providers.md +60 -27
  114. package/docs/02-sync/07-linear.md +129 -0
  115. package/docs/03-guides/04-triage.md +1 -1
  116. package/docs/04-reference/01-cli.md +26 -6
  117. package/docs/04-reference/02-board.md +18 -5
  118. package/docs/04-reference/03-http.md +5 -2
  119. package/docs/04-reference/05-conformance.md +1 -1
  120. package/docs/index.md +5 -10
  121. package/package.json +31 -6
  122. package/dist/a2-D24cvwpN.d.mts +0 -3359
  123. package/dist/access-CJU2-vaa.mjs +0 -226
  124. package/dist/attempts-Bxp9KgPf.mjs +0 -919
  125. package/dist/exports-DLiE7fLY.d.mts +0 -4
  126. package/dist/provider-CuewcDNZ.mjs +0 -205
  127. package/dist/redis-tables-B35vv4CZ.mjs +0 -842
  128. package/dist/reducers-B_vUGQRp.d.mts +0 -1172
  129. package/dist/watchers-hash-CLjsa6od.mjs +0 -225
  130. package/docs/02-sync/02-callbacks.md +0 -265
@@ -16,9 +16,10 @@ outbound vb reply ─▶ message.posted ─▶ sync ─▶ Slack ─▶ receipt
16
16
  attach vb sync ─▶ receipt: thread.synced ─▶ sync ─▶ Slack (root + backfill)
17
17
  ```
18
18
 
19
- Sync is configured per platform, keyed by the platform's own things: Slack
20
- channels, GitHub repos, WhatsApp numbers. Each key maps to a board channel (a
21
- two-way mirror) or to a function that handles inbound events however you like.
19
+ Each platform takes one sync function, which board calls for every event in
20
+ both directions: it imports what comes in, and delivers what goes out. Helpers
21
+ build it from the platform's own things (Slack channels, GitHub repos, WhatsApp
22
+ numbers), each mapped to a board channel or to a function of your own.
22
23
 
23
24
  Sync runs in `board()`, wherever the board lives. It keeps its records on the
24
25
  backend through routes every backend answers: imports as platform authors,
@@ -33,8 +34,8 @@ Each entry under `sync` is one platform account, keyed by a name you choose:
33
34
  ```ts app/api/board/[[...path]]/route.ts
34
35
  import { board } from '@vercel/board'
35
36
  import { a2 } from '@vercel/board/backend-a2'
36
- import { slack } from '@vercel/board/sync-slack'
37
- import { github } from '@vercel/board/sync-github'
37
+ import { channels, slack } from '@vercel/board/sync-slack'
38
+ import { github, repos } from '@vercel/board/sync-github'
38
39
  import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
39
40
  import { postgres } from '@vercel/board/backend-a2/store-postgres'
40
41
 
@@ -46,19 +47,14 @@ const backend = a2({
46
47
  const team = board({
47
48
  backend,
48
49
  sync: {
49
- slack: slack({
50
- adapter: 'slack/acme', // a Vercel Connect connector
51
- channels: {
52
- '#errors-www': 'errors-www',
53
- '#prs': 'prs',
54
- },
55
- }),
56
- github: github({
57
- adapter: 'github/acme',
58
- repos: {
59
- 'acme/www': { pulls: 'prs', issues: 'issues' },
60
- },
61
- }),
50
+ // A Vercel Connect connector, and what each event does.
51
+ slack: slack('slack/acme', channels({
52
+ '#errors-www': 'errors-www',
53
+ '#prs': 'prs',
54
+ })),
55
+ github: github('github/acme', repos({
56
+ 'acme/www': { pulls: 'prs', issues: 'issues' },
57
+ })),
62
58
  },
63
59
  })
64
60
  export const GET = team.fetch, POST = team.fetch
@@ -82,50 +78,55 @@ covers the store, the scheduler, and their routes. To pass your own Chat SDK
82
78
  adapter instead of a connector, see [Slack](./03-slack.md#your-own-adapter) and
83
79
  [GitHub](./04-github.md#setup).
84
80
 
85
- ## Mappings
81
+ ## Sync functions
86
82
 
87
- Each mapping picks one of three behaviors:
83
+ A platform's first argument is its app: a connector name, or the options
84
+ (`{ adapter, … }`). The second is its sync function. The helpers make one from a
85
+ map, where a string is a board channel:
88
86
 
89
- | Value | What it does |
87
+ | Helper | A string means |
90
88
  | --- | --- |
91
- | `'errors-www'` | A two-way mirror. Inbound events import into that board channel; board roots there start external threads where the platform can. |
92
- | `async ({ event, board }) => {}` | A callback for every inbound event, connected threads included. It decides what to import. |
93
- | `{ channel, run }` | A callback, plus a static channel so board roots still start external threads. |
89
+ | `channels({ '#prs': 'prs' })` (Slack) | `mirror('prs')`: a two-way mirror. What comes in lands in `prs`, and a root posted in `prs` starts a thread in #prs. |
90
+ | `repos({ 'acme/www': 'prs' })` (GitHub) | `into('prs')`: PRs and issues come in, and nothing goes back unless a function sends it. |
91
+ | `numbers({ '16315558151': 'support' })` (WhatsApp) | `mirror('support')`. |
94
92
 
95
- The function forms are covered in [Callbacks](./02-callbacks.md).
93
+ A map's value can also be a function of your own, and the whole thing can be:
94
+ see [Sync functions](./02-functions.md).
96
95
 
97
96
  **Entry keys** (`slack`, `github`, or `acme` for a second Slack workspace) are
98
97
  names you choose; the adapter decides the platform. The key appears in external
99
98
  IDs (`acme:C0999XYZ:1727.9`), in sync targets, in `--from` filters, and in the
100
99
  webhook path (`/webhooks/acme`).
101
100
 
102
- **Mapping keys** are the platform's names or IDs: `'#prs'` or `'C0123ABC'` for
101
+ **Map keys** are the platform's names or IDs: `'#prs'` or `'C0123ABC'` for
103
102
  Slack, `'owner/repo'` for GitHub, a phone number ID for WhatsApp.
104
103
 
105
104
  - Slack names resolve through one cached channel listing. IDs survive renames;
106
105
  a renamed channel stops matching by name on the next cold start.
107
- - Each key resolves on its own. One that can't (a channel that doesn't exist
108
- yet, a missing scope) is logged and skipped, retried at most once a minute,
109
- and the rest sync.
106
+ - Each key resolves on its own. A channel that doesn't exist yet is logged and
107
+ skipped, retried at most once a minute, and the rest sync. One Slack couldn't
108
+ check (a failed listing) holds what it might be in; see
109
+ [Helpers](./02-functions.md#helpers).
110
110
 
111
111
  ## The rules
112
112
 
113
113
  1. **Delivery.** Every message, edit, delete, and reaction in a thread goes to
114
- every external thread it's synced with, except the one it came from. Where a
115
- platform can't do something (WhatsApp edits, GitHub typing), that delivery is
116
- skipped.
114
+ the sync function once for each external thread it's synced with, except the
115
+ one it came from, and `deliver()` sends it. Where a platform can't do
116
+ something (WhatsApp edits, GitHub typing), that delivery is skipped.
117
117
  2. **Starting threads.** A root posted on the board, by anyone or any watcher,
118
- starts a thread in each mirrored channel on platforms that can start threads in
119
- a channel. Slack can. GitHub and WhatsApp can't, so roots never open an issue
120
- or a conversation.
121
- 3. **Inbound.** A platform event is handled when its thread is already synced
122
- (it becomes a reply, reaction, edit, or delete) or when its channel is in
123
- config (it goes to the mirror or your function). Everything else is ignored,
124
- even in channels the bot belongs to.
125
- 4. **Targets are resolved once,** when a root is posted. A root posted through
126
- this board lands with them, as `thread.synced` events in the same batch; one
127
- written straight on the board it uses gets them when sync sees it. Either
128
- way, a deploy that changes mappings affects new threads only.
118
+ goes to the sync function of each platform that can start threads in a
119
+ channel, which may `start` one; `mirror` does in its channel. Slack and
120
+ Linear can; on Linear a root opens an issue. GitHub and WhatsApp can't, so
121
+ roots never open an issue or a conversation there.
122
+ 3. **Inbound.** A platform event goes to the sync function when its thread is
123
+ already synced, or when its channel is one the helpers route (their keys;
124
+ a function of your own at the top gets every channel). The function decides
125
+ whether it lands. Everything else is
126
+ dropped at the webhook, even in channels the bot belongs to.
127
+ 4. **Threads start once,** when their root is posted, or when someone syncs
128
+ them by hand. A deploy that changes a function changes what it does from
129
+ then on: threads it started stay synced, and what reaches them is up to it.
129
130
  5. **Attaching.** `vb sync <id> <target>` attaches an existing thread. A channel
130
131
  target gets the root and a backfill of every reply, in order. A thread target
131
132
  joins an existing external thread and receives only what's written after.
@@ -152,6 +153,28 @@ vb sync 812 slack:#incidents # escalate an exi
152
153
  vb sync 812 github:acme/www:123 # join PR #123's conversation
153
154
  ```
154
155
 
156
+ `vb sync 812` with no target says where the thread is: each target, and the
157
+ platform's link to #812's copy there. `vb read <link>` goes the other way, from
158
+ a Slack permalink or a GitHub PR, issue, or comment to the board thread it's
159
+ synced with. Both answer from sync's records, never from the platform, and only
160
+ for messages you can read: a link to anything else is `no message on this board
161
+ for that link`. A handle has them as `board.sync(id)` and `board.linked(url)`.
162
+
163
+ ```text
164
+ $ vb sync 812
165
+ slack C0C8XN8SQRG https://vercel.slack.com/archives/C0C8XN8SQRG/p1792780353701549
166
+
167
+ $ vb read https://vercel.slack.com/archives/C0C8XN8SQRG/p1792780353701549
168
+ #812 ada · 2h
169
+ checkout is down
170
+ ```
171
+
172
+ The links come from each provider: its `locate(url)` reads a link as the
173
+ platform's own IDs, and `url(thread, message)` makes one. A board with several
174
+ entries of one platform (two Slack workspaces, or a project's Slack sync
175
+ registered beside the team's) looks the IDs up under each of them, since any
176
+ may have synced it. See [Providers](./06-providers.md#links).
177
+
155
178
  `--no-sync` keeps a message on the board: a root with it starts no threads in
156
179
  mirrored channels, and a reply with it isn't delivered. `--sync` and `--no-sync`
157
180
  together are a usage error. A handle's `post` takes `sync: [...]` and
@@ -165,12 +188,12 @@ its sync still runs here:
165
188
  ```ts app/api/board/[[...path]]/route.ts
166
189
  import { board } from '@vercel/board'
167
190
  import { http } from '@vercel/board/backend-http'
168
- import { slack } from '@vercel/board/sync-slack'
191
+ import { channels, slack } from '@vercel/board/sync-slack'
169
192
 
170
193
  const team = board({
171
194
  backend: http('https://board.example.com/api/board'),
172
195
  sync: {
173
- slack: slack({ adapter: 'slack/acme', channels: { '#support': 'support' } }),
196
+ slack: slack('slack/acme', channels({ '#support': 'support' })),
174
197
  },
175
198
  })
176
199
  export const GET = team.fetch, POST = team.fetch
@@ -187,7 +210,7 @@ export const GET = team.fetch, POST = team.fetch
187
210
  and read them: `vercelProject({ project: 'prj_support', can: { support: 'all' } })`
188
211
  in its `board({ access })`. See [what `sync` grants](../04-reference/03-http.md#what-sync-grants).
189
212
 
190
- A [callback](./02-callbacks.md)'s writes land on the other board as the
213
+ A [sync function](./02-functions.md)'s writes land on the other board as the
191
214
  platform user, and its reads are this deployment's. This board registers its
192
215
  entries there, so the other board lists them and links accounts on them. People
193
216
  link the other board, so `vb identity link slack` gets its code there; this
@@ -233,7 +256,8 @@ branch, a custom environment).
233
256
  **Without Connect,** create the app on the platform yourself, point its webhook
234
257
  at the same route, and pass the platform's own secrets to the Chat SDK adapter.
235
258
  Each platform page has the steps: [Slack](./03-slack.md#setup),
236
- [GitHub](./04-github.md#setup), [WhatsApp](./05-whatsapp.md#setup).
259
+ [GitHub](./04-github.md#setup), [WhatsApp](./05-whatsapp.md#setup),
260
+ [Linear](./07-linear.md#setup).
237
261
 
238
262
  ## When delivery fails
239
263
 
@@ -254,7 +278,7 @@ and logs why.
254
278
  On the way in, the webhook stores each event in the inbox before it answers,
255
279
  then sync imports it on the external thread's lane. If storing fails, the
256
280
  webhook answers the platform with a 500 so it retries. On `a2()`, a
257
- [callback](./02-callbacks.md) that keeps throwing dead-letters after 10 tries,
281
+ [sync function](./02-functions.md) that keeps throwing dead-letters after 10 tries,
258
282
  which holds up only that thread's lane in that channel's inbox. A board on
259
283
  `http()` gives the event up after 5 tries, as it does a watcher's match.
260
284
 
@@ -0,0 +1,398 @@
1
+ ---
2
+ title: Sync functions
3
+ description: One function per platform decides what each event does, coming in and going out.
4
+ ---
5
+
6
+ ## One function per platform
7
+
8
+ Each platform takes its app and a sync function. board calls the function for
9
+ every event, in both directions, and the function decides what happens:
10
+
11
+ ```ts app/api/board/[[...path]]/route.ts
12
+ import { board } from '@vercel/board'
13
+ import { a2 } from '@vercel/board/backend-a2'
14
+ import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
15
+ import { postgres } from '@vercel/board/backend-a2/store-postgres'
16
+ import { slack } from '@vercel/board/sync-slack'
17
+
18
+ const backend = a2({
19
+ store: postgres({ connectionString: process.env.DATABASE_URL! }),
20
+ scheduler: vercelQueues({ topic: 'board' }),
21
+ })
22
+
23
+ const team = board({
24
+ backend,
25
+ sync: {
26
+ slack: slack('slack/acme', async (sync) => {
27
+ // From Slack: #support (C0123ABC) comes onto the board, and threads it already has keep coming.
28
+ if (sync.direction === 'in') return void (await sync.import(sync.channel === 'C0123ABC' ? 'support' : undefined))
29
+ // A new root in `support`: start its thread in #support.
30
+ if (sync.target === null) return sync.message.channel === 'support' ? sync.start('C0123ABC') : undefined
31
+ // Anything said on the board, in a thread synced to Slack: send it.
32
+ await sync.deliver()
33
+ }),
34
+ },
35
+ })
36
+ export const GET = team.fetch, POST = team.fetch
37
+ export const { scheduler } = backend
38
+ ```
39
+
40
+ A function that calls nothing does nothing: nothing comes in, nothing goes out.
41
+ `import`, `deliver`, and `start` are the only things that move a message.
42
+
43
+ Most boards don't write that function by hand. `channels`, `repos`, and
44
+ `numbers` route each event by its platform channel, and `mirror`, `into`, and
45
+ `render` are what each channel does:
46
+
47
+ ```ts
48
+ // in board({ sync: { … } }):
49
+ slack: slack('slack/acme', channels({ C0123ABC: 'support' })),
50
+ ```
51
+
52
+ That's the function above, except that the webhook keeps only #support's events
53
+ and those of threads the board syncs; see [What the webhook keeps](#what-the-webhook-keeps)
54
+ and [Helpers](#helpers).
55
+
56
+ ## Coming in
57
+
58
+ When an event comes from the platform, `sync.direction` is `'in'`:
59
+
60
+ | Field | Value |
61
+ | --- | --- |
62
+ | `key` | The entry's key: `slack` in `sync: { slack: … }`. |
63
+ | `channel` | The platform channel it happened in, as the platform names it: `C0123ABC`, `acme/www:pulls`, a phone number ID. |
64
+ | `event` | The event, validated and frozen. See [The event](#the-event). |
65
+ | `board` | The board, as the platform user the event came from. See [More than an import](#more-than-an-import). |
66
+ | `import(channel?, overrides?)` | Imports the event. See below. |
67
+ | `permalink()` | The platform's own link to the event's message. See [Permalinks](#permalinks). |
68
+ | `resolve(name)` | A platform channel's ID from its name: `'#support'` to `'C0123ABC'`. |
69
+
70
+ `import(channel)` applies the default import: provenance, files, links, state
71
+ labels, and retries. For an event in a thread the board already syncs, the
72
+ channel is ignored and the event lands in that thread; the channel only places
73
+ new roots. Without a channel, an event in a thread the board doesn't sync stays
74
+ off it, so `import()` alone means "only threads we already have".
75
+
76
+ It runs once per event: a second call answers what the first did. Don't call
77
+ it, and the event is skipped; its thread stays connected, so later events still
78
+ reach the function.
79
+
80
+ ## Going out
81
+
82
+ When something happens on the board, `sync.direction` is `'out'`, once for each
83
+ platform thread the board thread is synced with:
84
+
85
+ | Field | Value |
86
+ | --- | --- |
87
+ | `key` | The entry's key. |
88
+ | `target` | The platform thread or channel it goes to (`slack:C0123ABC:1727000000.123456`), or `null` for a new root. |
89
+ | `channel` | The platform channel `target` is in, as the platform names it; `null` for a new root. |
90
+ | `event` | The board event, as `vb tail` shows it. |
91
+ | `message` | The message the event is about, as it is now. |
92
+ | `deliver(options?)` | Sends the event to `target`. Takes `{ render }`; see [Render](#render). |
93
+ | `start(channel)` | Syncs the message's thread with a platform channel: `'#support'`, `'C0123ABC'`. |
94
+ | `resolve(name)` | A platform channel's ID from its name. |
95
+
96
+ What `deliver()` sends depends on the event:
97
+
98
+ | Event | `deliver()` sends |
99
+ | --- | --- |
100
+ | `thread.synced` | The root, then every reply, in order: a thread that just started there. |
101
+ | `message.posted` | The reply. |
102
+ | `message.edited`, `message.deleted` | The change to its copy. |
103
+ | `reaction.added`, `reaction.removed` | The reaction on its copy. |
104
+ | `labels.changed` | An edit of its copy, when the labels change what it renders to. |
105
+
106
+ A new root comes once more, with `target` and `channel` null, for each platform
107
+ that can start a thread in a channel (Slack can; GitHub and WhatsApp can't).
108
+ That's when `start` puts it there: the board records the channel, and the
109
+ `thread.synced` that follows comes back through the function to be delivered.
110
+ `start` never sends a thread back to the channel its root came from. A root
111
+ posted with `--no-sync` doesn't come at all.
112
+
113
+ The function is never called with a message's way back to where it came from:
114
+ a Slack message isn't offered to its own Slack thread. `deliver` runs once per
115
+ call, like `import`.
116
+
117
+ ## Helpers
118
+
119
+ `channels`, `repos`, `numbers`, and `teams` take a map from platform channels to what
120
+ each one does: a sync function, or a board channel as a string.
121
+
122
+ | Helper | From | Keys | A string means |
123
+ | --- | --- | --- | --- |
124
+ | `channels` | `@vercel/board/sync-slack`, `@vercel/board/sync` | Slack channels, by name (`'#prs'`) or ID | `mirror` |
125
+ | `repos` | `@vercel/board/sync-github` | Repos (`'owner/repo'`), or `{ pulls, issues }` to route each | `into` |
126
+ | `numbers` | `@vercel/board/sync-kapso` | WhatsApp phone number IDs | `mirror` |
127
+ | `teams` | `@vercel/board/sync-linear` | Linear team keys (`'ENG'`) | `mirror` |
128
+
129
+ Each routes an event by its `channel`. A new root goes to every channel's
130
+ function, with `channel` set to that one. A thread the board syncs in a channel
131
+ that isn't in the map (someone ran `vb sync` on it) keeps syncing: both ways
132
+ under `channels`, `numbers`, and `teams`, and in only under `repos`.
133
+
134
+ The units are plain sync functions, from `@vercel/board/sync`:
135
+
136
+ | Unit | Does |
137
+ | --- | --- |
138
+ | `mirror(channel)` | Imports into the board channel, delivers what's said there, and starts a thread for a new root in it. |
139
+ | `into(channel)` | Imports into the board channel, and sends nothing back. |
140
+ | `render(renderer, fn)` | `fn`, delivering with `renderer`. |
141
+
142
+ They compose by calling each other, with the helper outermost (see [What the
143
+ webhook keeps](#what-the-webhook-keeps)). A GitHub repo that comes in, and gets
144
+ back only what's written on the board, never what was said in Slack:
145
+
146
+ ```ts
147
+ // in board({ sync: { … } }):
148
+ github: github('github/acme', repos({
149
+ 'acme/www': async (sync) => {
150
+ if (sync.direction === 'in') return void (await sync.import('prs'))
151
+ if (sync.message.from === null) await sync.deliver()
152
+ },
153
+ })),
154
+ ```
155
+
156
+ A PR's inline review threads stay on GitHub and the board, out of Slack:
157
+
158
+ ```ts
159
+ // in github('github/acme', repos({ … })):
160
+ 'acme/www': async (sync) => {
161
+ if (sync.direction === 'in') await sync.import('prs', { noSync: sync.event.subthread !== null })
162
+ },
163
+ ```
164
+
165
+ Keys resolve on their own. A channel that doesn't exist yet is logged and
166
+ skipped, tried again a minute later, and the rest sync; so is a private
167
+ channel's name on an app without `groups:read`, which never lists (map it by
168
+ ID). A name the platform couldn't check, because its listing failed, might be
169
+ any channel, so until it resolves, an event outside the channels that did
170
+ throws and is retried, rather than reaching the fallback past that key's
171
+ function, and a new root starts only where its channel resolved. Each retry
172
+ asks again. Slack names resolve through one cached channel listing. IDs never
173
+ wait: a key like `'C0123ABC'` asks Slack nothing.
174
+
175
+ ## What the webhook keeps
176
+
177
+ The webhook stores each event it keeps in the [inbox](./01-overview.md#debug-it),
178
+ then the function runs on it. It keeps an event when the board syncs its thread,
179
+ or when its channel is one that `channels`, `repos`, or `numbers` routes;
180
+ `render` keeps theirs. A function of your own at the top keeps every event the
181
+ platform sends the app, in every channel it's in, and each one is stored, even
182
+ if it only calls a helper inside:
183
+
184
+ ```ts
185
+ // in board({ sync: { … } }): keeps #support's events, and threads synced by hand
186
+ slack: slack('slack/acme', channels({ '#support': 'support' })),
187
+ // the same on the board, but keeps every channel the app is in
188
+ slack: slack('slack/acme', (sync) => channels({ '#support': 'support' })(sync)),
189
+ ```
190
+
191
+ So keep the helper outermost, and put your own code in its values.
192
+
193
+ ## The event
194
+
195
+ Every event is an envelope, validated and frozen the first time your function
196
+ reads it. Fields every event has:
197
+
198
+ | Field | Value |
199
+ | --- | --- |
200
+ | `id` | The inbox event's ID. Stable across retries: use it as an idempotency key. |
201
+ | `platform`, `key` | `slack`, `github`, or `whatsapp`, and the entry's key. |
202
+ | `channel`, `thread` | External IDs with the key prefix: `slack:C0123ABC`, `slack:C0123ABC:1727000000.123456`. `thread` is what `vb sync` accepts as a target. |
203
+ | `subthread` | The precise sub-thread, when the adapter grouped it: a GitHub review thread. Otherwise `null`. |
204
+ | `kind` | `message`, `edited`, `deleted`, `reaction`, or `lifecycle`. |
205
+ | `type` | The narrower name: `message` (Slack, WhatsApp), `comment` (GitHub), `message.edited`, `message.deleted`, `reaction.added`, `reaction.removed`, or a lifecycle type like `pr.opened`. |
206
+ | `raw` | The platform's payload. |
207
+
208
+ Then, by kind:
209
+
210
+ - **`message`:** `message` (the platform's ID), `text` (markdown), `author`
211
+ (`{ id, handle, name, avatar, isBot }`), `isRoot`, `context` (a GitHub review
212
+ comment's `path:line`), and `files` and `links` when it has them.
213
+ - **`edited`:** `message`, `text`, `author`, `links`.
214
+ - **`deleted`:** `message`.
215
+ - **`reaction`:** `message`, `emoji`, `added`, `author`.
216
+ - **`lifecycle`:** `type`, `title`, `url`, `labels`, `reply`, and `author`. GitHub
217
+ and Linear send these: see GitHub's [events table](./04-github.md#events) and
218
+ Linear's [events table](./07-linear.md#events).
219
+
220
+ GitHub events also carry `subject: { type: 'pull_request' | 'issue', repo, number }`,
221
+ comments and reactions included. The types are exported: `SlackEvent` from
222
+ `@vercel/board/sync-slack`, `GitHubEvent` and `GitHubPayload` from
223
+ `@vercel/board/sync-github`, `WhatsAppEvent` from `@vercel/board/sync-kapso`, and `LinearEvent` from
224
+ `@vercel/board/sync-linear`.
225
+
226
+ ## Permalinks
227
+
228
+ `permalink()` is the platform's own link to the event's message. It's fetched on
229
+ the first call and reused after, so a function that never asks doesn't cost an
230
+ API request:
231
+
232
+ ```ts
233
+ // in slack('slack/acme', channels({ … })): copy a report elsewhere, with a link back
234
+ '#reports': async (sync) => {
235
+ if (sync.direction === 'in' && sync.event.kind === 'message' && sync.event.isRoot)
236
+ await sync.board.post('triage', `${sync.event.text}\n\n${await sync.permalink()}`)
237
+ },
238
+ ```
239
+
240
+ On Slack it calls `chat.getPermalink` with the provider's client, so the URL has
241
+ the workspace's own host (Enterprise Grid included) and a reply's `thread_ts`. It
242
+ only reads the URL: the thread isn't synced and no channel is joined. It returns
243
+ `null` for a lifecycle event or a deleted message, and on platforms without
244
+ links, and throws when Slack refuses, so the event is retried.
245
+
246
+ ## Overrides
247
+
248
+ `import`'s second argument changes the default import:
249
+
250
+ | Override | Effect |
251
+ | --- | --- |
252
+ | `text` | Replaces the imported message's text. For a lifecycle event that creates a root and a reply, it replaces the reply; for one that only creates a root, it's the root's final text. |
253
+ | `labels` | Merged into the thread root's labels. |
254
+ | `unset` | Label keys removed from the root, applied last. |
255
+ | `root` | A synchronous function that shapes a new root. It receives the default draft (`text`, `labels`, `author`, `raw`) and returns `{ text?, labels? }`. |
256
+ | `noSync` | Keeps the imported reply on the board, like `vb reply --no-sync`: no other platform the thread syncs to gets it, now or in a backfill. A root it creates still syncs. |
257
+
258
+ `root` runs whenever an import creates a root: a PR opening, or a comment on a PR
259
+ the board hasn't seen, which recovers the root first. Its labels merge into the
260
+ draft, then `labels` takes precedence, then `unset` removes keys. It can't change
261
+ the author or other provenance:
262
+
263
+ ```ts
264
+ // in github('github/acme', repos({ … })):
265
+ 'acme/www': async (sync) => {
266
+ if (sync.direction !== 'in') return
267
+ await sync.import(sync.event.subject.type === 'pull_request' ? 'prs' : 'issues', {
268
+ labels: { area: 'www' },
269
+ root: ({ raw }) => {
270
+ const payload = GitHubPayload.parse(raw)
271
+ const item = payload.pull_request ?? payload.issue
272
+ return { text: [item?.title, item?.body, item?.html_url].filter(Boolean).join('\n\n') }
273
+ },
274
+ })
275
+ },
276
+ ```
277
+
278
+ Platform reconciliation still applies on later events: GitHub's next event
279
+ rewrites `state`, `review`, `tag.*`, and `assignee.*` unless you override them
280
+ again.
281
+
282
+ ## The result
283
+
284
+ `import` returns `{ root, message }`, the board IDs it touched:
285
+
286
+ - `root` is the thread's root.
287
+ - `message` is the imported message, the message an edit, delete, or reaction
288
+ changed, or a lifecycle event's reply. A lifecycle event that only creates a root
289
+ returns the root; one that only changes labels returns `null`.
290
+ - An event that can't apply (a reaction on a message the board never had) returns
291
+ `null` where no board message exists.
292
+
293
+ Retried after it succeeded, it returns the same IDs.
294
+
295
+ ## More than an import
296
+
297
+ `sync.board` is a client of the board in this process, through `board()`, with
298
+ the [handle's](../04-reference/02-board.md) methods, so a function can also
299
+ reply, react, label, or attach:
300
+
301
+ ```ts
302
+ // in slack('slack/acme', channels({ … })):
303
+ '#support': async (sync) => {
304
+ if (sync.direction === 'out') return sync.deliver()
305
+ const { root } = await sync.import('support')
306
+ if (sync.event.kind === 'message' && sync.event.isRoot && root !== null)
307
+ await sync.board.label(root, { set: { status: 'new' } })
308
+ },
309
+ ```
310
+
311
+ Its writes (a reply, a label, a reaction, an edit or delete of the user's own
312
+ message) are authored by the platform actor who triggered the event, like
313
+ `slack:user:U0456`, with the same provenance as the import, and go through the
314
+ [middleware](../01-board/08-middleware.md) like it. Its reads, and anything else
315
+ it does, are sync's own. It holds no token: `board()` makes its calls. It works
316
+ the same on any backend, a board on `http()` included: there the writes land on
317
+ the board it uses as imports.
318
+
319
+ ## Render
320
+
321
+ `deliver({ render })` decides the body of each board message it posts, and
322
+ `render(renderer, fn)` passes a renderer to every `deliver` in `fn`. A renderer
323
+ receives immutable copies of the message, as clients see it (its IDs are
324
+ strings), and its author, the default label, and the resolved destination, and
325
+ returns markdown:
326
+
327
+ ```ts
328
+ // in board({ sync: { … } }): post the text alone, without the author label
329
+ kapso({ adapter, markRead }, render(({ message }) => message.text, numbers({ '16315558151': 'support' }))),
330
+ ```
331
+
332
+ The default is `**${label}**: ${message.text}`, where the label is the author's
333
+ name: `claude via julian` for an agent profile, `ada (slack)` for a message that
334
+ came from a platform. board still adds a line per attachment the platform
335
+ didn't take, and converts mentions to the platform's own form after your
336
+ renderer runs.
337
+
338
+ Each channel can render its own way, since `render` wraps any function:
339
+
340
+ ```ts
341
+ // in board({ sync: { … } }):
342
+ slack: slack('slack/acme', channels({
343
+ '#deploys': render(({ message }) => message.text, mirror('deploys')),
344
+ '#support': mirror('support'),
345
+ })),
346
+ ```
347
+
348
+ A renderer can read labels. When a label change changes what a delivered copy
349
+ renders to, board edits that copy, so a PR's root in Slack can show its state:
350
+
351
+ ```ts
352
+ // in slack('slack/acme', render(…, channels({ … }))):
353
+ ({ message, label }) =>
354
+ message.re === null && message.labels.state === 'merged'
355
+ ? `:pr-merged: ${message.text}`
356
+ : `**${label}**: ${message.text}`
357
+ ```
358
+
359
+ `mentionOf(actor)` returns how the platform mentions an actor (`<@U0456>` in
360
+ Slack), through any account their person linked, or null. The default label is
361
+ the author's name, so nobody gets pinged unless a renderer asks:
362
+
363
+ ```ts
364
+ // a renderer: a PR's root mentions its owner, or its author
365
+ ({ message, label, mentionOf }) => {
366
+ const who = (message.labels.owner && mentionOf(message.labels.owner)) ?? mentionOf(message.by.actor)
367
+ return message.re === null && message.labels.pr && who
368
+ ? `${message.text} · ${who}`
369
+ : `**${label}**: ${message.text}`
370
+ }
371
+ ```
372
+
373
+ The default renderer reads no labels, so labels alone never edit a copy. Rendering
374
+ applies to roots, replies, backfills, and edits; changing it affects future
375
+ deliveries, not copies already sent. A subclass can override `render(input)`
376
+ instead; see [custom providers](./06-providers.md).
377
+
378
+ ## Retries and idempotency
379
+
380
+ A function that throws is retried. Writes made through `sync.board` take IDs
381
+ derived from the inbox event and the order of calls, so a retry that writes in
382
+ the same order doesn't post twice, and an import that already succeeded returns
383
+ its existing message. Two rules follow:
384
+
385
+ - Writes made in parallel (`Promise.all`) need explicit `id`s.
386
+ - Your own side effects aren't covered. Key them on `event.id`:
387
+
388
+ ```ts
389
+ // in a sync function, your side effect:
390
+ // await createTicket(sync.event, { idempotencyKey: sync.event.id })
391
+ ```
392
+
393
+ On `a2()`, an event coming in that keeps throwing dead-letters after 10 tries.
394
+ That holds up only its thread's lane in that channel's inbox; other threads and
395
+ the board keep flowing. A board on `http()` gives the event up after 5. Going
396
+ out, a throw is a failed delivery: retried five times, then recorded, as in
397
+ [When delivery fails](./01-overview.md#when-delivery-fails). Calling `import`,
398
+ `deliver`, or `start` after the function returns throws.