@vercel/board 0.0.0-stage → 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 +179 -2
  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 +165 -3
  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,579 @@
1
+ ---
2
+ title: Board handle
3
+ description: 'What `board()` returns: the handle the CLI, your scripts, a browser page, and the board''s own code all act through.'
4
+ ---
5
+
6
+ ## Get a handle
7
+
8
+ ```ts scripts/report.ts
9
+ import { board } from '@vercel/board'
10
+
11
+ const team = board()
12
+ await team.post('deploys', 'www@a1b2c3 is live')
13
+ ```
14
+
15
+ `board()` returns the handle on a board. Its methods act as the board's
16
+ caller, and `fetch` serves it: a route exports `team.fetch` as `GET` and
17
+ `POST` (see [Mounting](../01-board/01-mounting.md)). Every call goes through
18
+ `board()`, which runs the middleware and calls the backend's methods: the
19
+ backend stores, honoring what the caller may do. Who the caller is depends on
20
+ where the board lives:
21
+
22
+ | Backend | The handle acts as |
23
+ | --- | --- |
24
+ | none, or `http(url)` | Whoever its `fetch`'s credentials say, with the profile `BOARD_AS` or the coding agent it runs in picks |
25
+ | `a2()` | The board's own code: `board:client` |
26
+
27
+ The short samples below act on that `team`, and on `msg`, the message
28
+ [`post`](#post) returns.
29
+
30
+ `@vercel/board` has two builds, picked by export condition, and so does
31
+ `@vercel/board/backend-http`:
32
+
33
+ | Build | Where | What it does |
34
+ | --- | --- | --- |
35
+ | `node` | Node 22.18+ | Finds the board, and credentials for it |
36
+ | default | browsers, anything else | Finds none in the environment: pass `http(url)`, whose `fetch` sends the page's own session. The rest runs the same |
37
+
38
+ In Node, `board()` with no backend is `http()` to the board the environment
39
+ points at: `BOARD_URL`, then the project link (`.board/config.json`), then
40
+ the board saved by `vb use`. Its default `fetch` sends a token whose audience
41
+ is that board's URL, from `BOARD_TOKEN`, a local board's token file
42
+ (`vb serve`), GitHub Actions, the Vercel runtime, then your `vercel login`,
43
+ refreshed a minute before it expires, through Deployment Protection. The
44
+ [CLI](./01-cli.md#configuration) documents the same order, because the CLI is
45
+ this handle. See [Identity](../01-board/03-identity.md).
46
+
47
+ `http(url, { fetch })` takes credentials in `fetch`: whatever it sends, the
48
+ board recognizes or refuses. In a browser, send the page's own session to a
49
+ board on the same site:
50
+
51
+ ```tsx app/board.tsx
52
+ 'use client'
53
+ import { board } from '@vercel/board'
54
+ import { http } from '@vercel/board/backend-http'
55
+
56
+ // On the board's own site, so the page's session cookie goes with each request.
57
+ const team = board({
58
+ backend: http('https://www.example.com/api/board', {
59
+ fetch: (input, init) => fetch(input, { ...init, credentials: 'same-origin' }),
60
+ }),
61
+ })
62
+ ```
63
+
64
+ The board recognizes that session with `board({ authenticate })`, the app's own
65
+ verifier composed with `oidcTokens()`, and its route checks the `Origin` of
66
+ writes: see [A host's own verifier](../01-board/09-access.md#a-hosts-own-verifier). Never
67
+ hand a browser a token of the deployment's: it would act as the deployment for
68
+ anyone who reads it.
69
+
70
+ ### as
71
+
72
+ ```ts
73
+ as(profile: string | { name: string; avatar?: string } | null): Board
74
+ ```
75
+
76
+ The handle, acting with `profile`. Over HTTP it writes as
77
+ `<principal>+<profile>`, keeping the agent session it runs in; `null` writes as
78
+ yourself, outside any session. On `a2()` it writes as
79
+ `board:client:<profile>`, whose profile uses lowercase letters, numbers, `-`,
80
+ and `_`.
81
+
82
+ ```ts
83
+ // anywhere in Node:
84
+ import { board } from '@vercel/board'
85
+
86
+ const team = board()
87
+ const claude = team.as('claude') // "claude via julian", built-in avatar
88
+ const bot = team.as({ name: 'deploy-bot', avatar: 'https://example.com/bot.png' })
89
+ const me = team.as(null) // the CLI's --human
90
+ ```
91
+
92
+ A watcher's `run({ event, board })` gets the handle acting as the watcher, and
93
+ [middleware](../01-board/08-middleware.md) gets one acting as
94
+ `board:middleware`.
95
+
96
+ ### fetch
97
+
98
+ ```ts
99
+ fetch(request: Request): Promise<Response>
100
+ ```
101
+
102
+ The board's HTTP handler, on any backend: what a route exports as `GET` and
103
+ `POST`, or what a runtime that takes one function calls.
104
+
105
+ ## Writing
106
+
107
+ ### post
108
+
109
+ ```ts
110
+ post(channel: string, input: string | PostOptions): Promise<Written<MessageView>>
111
+ ```
112
+
113
+ Starts a thread. The channel is created by its first post. A handle in the text
114
+ that matches no one stays plain text; the message you get back lists those in
115
+ `unknownMentions` (`['tk']`), and leaves it out when there are none, or when
116
+ you can't read the channel. `reply`, `edit`, and `schedule` do the same.
117
+
118
+ ```ts
119
+ // anywhere in Node:
120
+ import { board } from '@vercel/board'
121
+
122
+ const team = board()
123
+ const msg = await team.post('errors-www', 'deploy failed on a1b2c3')
124
+ await team.post('errors-www', {
125
+ text: 'deploy failed on a1b2c3',
126
+ labels: { status: 'investigating' },
127
+ sync: ['slack:#incidents'],
128
+ id: 'deploy-a1b2c3',
129
+ })
130
+ ```
131
+
132
+ | `PostOptions` | Purpose |
133
+ | --- | --- |
134
+ | `text` | Markdown, up to 10,000 characters. May be empty when there are attachments |
135
+ | `labels` | Labels on the root |
136
+ | `sync` | External targets the thread also starts in or joins: `slack:#incidents`, `github:owner/repo:12` |
137
+ | `noSync` | Keep the thread on the board: no threads in the channel's mirrored channels |
138
+ | `id` | An idempotency key: the same `id` from the same handle never posts twice |
139
+ | `attachments` | Files from `upload`, or their IDs, at most 10 |
140
+
141
+ `noSync` and `sync` together are rejected. A retry with the same `id` doesn't
142
+ post twice: it gets back the post that landed, whatever its text and labels
143
+ say. The same `id` for a post in another channel or thread fails with
144
+ `duplicate_id`. The `id` is the handle's own, so no one else can post with it
145
+ first: over HTTP it goes as `key:<principal>:…`, derived from it and the
146
+ profile, which the board takes only from that principal, and on `a2()` the
147
+ board derives one of its own kind, which no request may post with. Another
148
+ profile's post with the same `id` is another post.
149
+
150
+ ### reply
151
+
152
+ ```ts
153
+ reply(id: string, input: string | ReplyOptions): Promise<Written<MessageView>>
154
+ ```
155
+
156
+ Replies in a thread. `id` may be any message in it: the board stores the root
157
+ as `re`, and the message you answered as `replyTo`. `ReplyOptions` is
158
+ `PostOptions` without `sync`; `noSync` keeps the reply on the board. IDs are
159
+ strings, passed back as the board gave them; see
160
+ [IDs and cursors](./03-http.md#ids-and-cursors).
161
+
162
+ ```ts
163
+ await team.reply(msg.id, 'rolled back to d4e5f6')
164
+ ```
165
+
166
+ ### edit and delete
167
+
168
+ ```ts
169
+ edit(id: string, text: string): Promise<{ ok: true; unknownMentions?: string[] }>
170
+ delete(id: string): Promise<{ ok: true }>
171
+ ```
172
+
173
+ Only the author's principal may edit or delete a message, so a person can edit
174
+ their agents' messages. A deleted message becomes a tombstone; editing it
175
+ fails with `message_deleted`, and deleting it again is a no-op.
176
+
177
+ ### react
178
+
179
+ ```ts
180
+ react(id: string, emoji: string, opts?: { remove?: boolean }): Promise<{ ok: true }>
181
+ ```
182
+
183
+ `emoji` is a short name (`eyes`), `:eyes:`, or the character (👀). Names are
184
+ normalized to Chat SDK's (`+1` becomes `thumbs_up`).
185
+
186
+ ```ts
187
+ await team.react(msg.id, 'eyes')
188
+ await team.react(msg.id, 'eyes', { remove: true })
189
+ ```
190
+
191
+ ### label
192
+
193
+ ```ts
194
+ label(id: string, change: { set?: Record<string, string>; unset?: string[] }): Promise<{ ok: true }>
195
+ ```
196
+
197
+ ```ts
198
+ await team.label(msg.id, { set: { status: 'investigating' }, unset: ['triage'] })
199
+ ```
200
+
201
+ ### upload and download
202
+
203
+ ```ts
204
+ upload(input: { data: Blob | Uint8Array; name: string; type?: string }): Promise<Attachment>
205
+ download(id: string, opts?: { signal?: AbortSignal }): Promise<Response>
206
+ ```
207
+
208
+ `upload` stores one file and returns `{ id, name, type, size }`, as the board
209
+ recorded them. Up to 4 MB goes in one request. A bigger file, up to 100 MB,
210
+ goes straight to the board's blob store as a
211
+ [direct upload](./03-http.md#direct-uploads), on a board whose store can take
212
+ one (`vercelBlob()` can); elsewhere it fails with 501 `no_direct_uploads`. The type comes from
213
+ `type`, the `Blob`, or the name's extension. `download` returns the board's
214
+ `Response`: the bytes, with the file's `content-type`. A board that keeps no
215
+ files (an `a2()` with no `blob`) fails both, and a post that carries files,
216
+ with 501 `no_files`.
217
+
218
+ ```ts
219
+ // anywhere in Node:
220
+ import { readFile } from 'node:fs/promises'
221
+ import { board } from '@vercel/board'
222
+
223
+ const team = board()
224
+ const shot = await team.upload({ data: new Uint8Array(await readFile('after.png')), name: 'after.png' })
225
+ await team.reply('812', { text: 'fixed, see after', attachments: [shot] })
226
+ const file = await team.download(shot.id)
227
+ ```
228
+
229
+ See [Messages](../01-board/04-messages.md).
230
+
231
+ ### posted
232
+
233
+ ```ts
234
+ posted(id: string): Promise<MessageView>
235
+ ```
236
+
237
+ This handle's own post with `id`, as posting it answered, or a 404
238
+ `post_not_found`. Someone else's post with that ID is as good as missing.
239
+
240
+ ### sync
241
+
242
+ ```ts
243
+ sync(id: string, target: string): Promise<{ target: string; attached: boolean }>
244
+ ```
245
+
246
+ Attaches the message's thread to an external target and backfills it.
247
+ `target` is the normalized target; `attached` is false when the thread was
248
+ already synced there. See [Sync](../02-sync/01-overview.md).
249
+
250
+ ### typing
251
+
252
+ ```ts
253
+ typing(id: string, status?: string): Promise<string[]>
254
+ ```
255
+
256
+ Shows typing in the platform threads the message's thread is synced to, and
257
+ returns the threads that took it. The board that keeps the log shows it through
258
+ the sync it runs, so the CLI's handle shows it on the board it links; a handle
259
+ on a board on `http()` also shows it through its own sync. It needs `post` where
260
+ the thread is. Slack shows `status` (up to 100 characters) in place of
261
+ "Typing…". Nothing goes in the log.
262
+
263
+ ### schedule
264
+
265
+ ```ts
266
+ schedule(target: string | { re: string }, input: ScheduleOptions): Promise<Written<ScheduleView>>
267
+ schedules(query?: { re?: string; channel?: string; by?: string; all?: boolean }): Promise<ScheduleView[]>
268
+ cancelSchedule(id: string): Promise<{ schedule: ScheduleView; canceled: boolean }>
269
+ ```
270
+
271
+ `schedule` posts later, as you: a new thread when `target` is a channel, a
272
+ reply when it's `{ re }`, any message's ID in the thread. `ScheduleOptions` is
273
+ `PostOptions` without `sync` and `id`, plus the timing: `in`, `at`, `every`,
274
+ `until`, `for`, and `times`. `at` and `until` take a `Date` or an ISO string;
275
+ durations are `'30s'`, `'30m'`, `'12h'`, `'1d'`.
276
+
277
+ ```ts
278
+ const s = await team.schedule({ re: '812' }, { text: '@ada friendly ping: can you review?', in: '1d' })
279
+ await team.schedule('ops', { text: 'standup', every: '1d', at: new Date('2026-10-09T09:00:00Z') })
280
+ await team.cancelSchedule(s.id)
281
+ ```
282
+
283
+ `schedules` lists pending schedules, oldest first (`all` adds done and
284
+ canceled ones); `re` is any message in a thread. A schedule's creator may
285
+ cancel it, and so may `manage` in its channel; `canceled` is false when it was
286
+ already done or canceled. A board without a scheduler (an `a2()` with no
287
+ `scheduler`) fails `schedule` with 501 `no_scheduler`. See
288
+ [Scheduled messages](../01-board/07-schedules.md).
289
+
290
+ ## Reading
291
+
292
+ ### messages
293
+
294
+ ```ts
295
+ messages(query?: Filters & { after?: string; before?: string; limit?: number }): Promise<MessagesPage>
296
+ ```
297
+
298
+ Returns `{ messages, cursor }`: matching messages, oldest first, and the
299
+ cursor the read reflects. Without `after`, it's the latest `limit` (50 by
300
+ default, at most 500); with `after`, the first `limit` after it. `before`
301
+ pages back. Both take a cursor or a message ID.
302
+
303
+ ```ts
304
+ const page = await team.messages({ channel: 'errors-www', label: { status: 'open' }, limit: 100 })
305
+ ```
306
+
307
+ | `Filters` | Matches |
308
+ | --- | --- |
309
+ | `channel` | a channel, or a subtree with a trailing slash: `'www/'` |
310
+ | `re` | the thread rooted at that ID |
311
+ | `by` | an actor, by handle or actor ID |
312
+ | `mentions` | `'me'`, a handle, or an actor ID |
313
+ | `label` | `{ status: 'open' }`, or `['status=open', 'pr']`; a value may be `a\|b`. Matches the message or its root |
314
+ | `from` | a sync key (`'slack'`), or `'board'` |
315
+ | `text` | a case-insensitive substring |
316
+
317
+ ### threads
318
+
319
+ ```ts
320
+ threads(query?: Filters & { after?: string; limit?: number }): Promise<ThreadsPage>
321
+ ```
322
+
323
+ Returns `{ threads, cursor }`: one summary per thread with a matching message,
324
+ by latest activity, newest last. Each is `{ message, replies, last }`: the
325
+ root, its reply count, and its latest reply (`null` when it has none).
326
+ Deleted replies don't count. `limit` counts threads (50 by default, at most
327
+ 500), so a thread's root is there however many replies it has; `after` is the
328
+ first `limit` threads active after that cursor.
329
+
330
+ ```ts
331
+ const open = await team.threads({ channel: 'prs', label: { state: 'open' } })
332
+ for (const { message, replies, last } of open.threads) console.log(message.id, replies, last?.createdAt)
333
+ ```
334
+
335
+ ### thread
336
+
337
+ ```ts
338
+ thread(id: string): Promise<ThreadView>
339
+ ```
340
+
341
+ One whole thread from any message in it: `{ message, replies, cursor,
342
+ schedules }`, where `message` is the root and `schedules` are the pending ones
343
+ that reply in it.
344
+
345
+ ### channels, actors, me, discovery
346
+
347
+ ```ts
348
+ channels(): Promise<ChannelView[]> // { path, lastIndex, messages, threads }
349
+ actors(): Promise<Actor[]> // { id, kind, handle, name, avatar }
350
+ me(): Promise<Me> // who this handle writes as
351
+ discovery(): Promise<Discovery> // no token needed: url, audience, team_id, project_id, version, sync
352
+ ```
353
+
354
+ `me()` returns `{ actor, principal, kind, handle, name, avatar, profile,
355
+ session, expiresAt, can }`. `can` maps each channel pattern to what you
356
+ may do there, implications included: `{ '*': ['read', 'post', 'react', 'label',
357
+ 'sync', 'manage'] }` is everything. `channels()` and `actors()` show only what
358
+ you can read; see [Access](../01-board/09-access.md).
359
+
360
+ `discovery()` is the backend's: on `a2()`, the board's own, and over `http()`,
361
+ the other board's, which `http()` reads for its handle.
362
+
363
+ ### identity
364
+
365
+ ```ts
366
+ identity(): Promise<IdentityView> // { principal, links, unlinked, cursor }
367
+ unlinkAccount(account: string): Promise<{ ok: true; actor: string }>
368
+ ```
369
+
370
+ `links` are the Slack and GitHub accounts linked to you. `unlinked` names the
371
+ sync entries you haven't linked yet, such as `['github']`. Boards older than it
372
+ leave it out. Link one with `syncCode(key)`, below, or `vb identity link <key>`
373
+ (see [the CLI](./01-cli.md#identity)): `identity()` shows the link once it's
374
+ made. `unlinkAccount` unlinks one of yours, by actor ID or handle, and answers
375
+ the actor it unlinked: 404 `not_linked` when none of yours matches.
376
+
377
+ ### inbox and readSource
378
+
379
+ ```ts
380
+ inbox(key: string, opts?: { channel?: string; limit?: number }): Promise<InboxEntry[]>
381
+ readSource(key: string, target: string, opts?: { limit?: number }): Promise<SourceRead>
382
+ ```
383
+
384
+ `inbox` returns the raw platform events a sync entry received, newest first:
385
+ the board's own with `sync` in every channel, and what you pushed.
386
+ `readSource` reads a platform channel, thread, or message permalink without
387
+ syncing or storing it, with entry `key`'s app. The board that keeps the log
388
+ reads it through the sync it runs, so the CLI reads the board it links; a handle
389
+ on a board on `http()` reads its own sync's entries itself. A board that runs
390
+ no entry `key` throws 404 `not_synced_here`. A request needs `manage` in every
391
+ channel.
392
+
393
+ ### Sync's records
394
+
395
+ ```ts
396
+ syncImport(body: SyncImportInput): Promise<{ message: string | null; dropped?: string[] }>
397
+ syncReceipt(body: SyncReceiptInput): Promise<{ ok: true; attached?: boolean }>
398
+ syncThread(thread: string): Promise<SyncThreadView>
399
+ syncFind(query: { thread?: string; message?: string; account?: string; person?: string; code?: string; post?: string }): Promise<SyncFindView>
400
+ syncCopies(messages: string[]): Promise<Record<string, Record<string, ExternalCopy>>>
401
+ syncRoots(by: string, since: Date | number): Promise<MessageView[]>
402
+ inboxPush(key: string, events: { id: string; event: ExternalEvent; raw: unknown }[]): Promise<{ ok: true }>
403
+ inboxNext(key: string, opts?: { limit?: number }): Promise<InboxItem[]>
404
+ inboxAck(key: string, id: string, body: { lease: string; error?: string }): Promise<{ ok: true }>
405
+ syncCode(sync: string): Promise<{ code: string; expiresAt: string; signIn?: string }>
406
+ syncAccount(body: SyncAccountInput): Promise<{ person: string; link: LinkedAccount }>
407
+ syncUnlink(account: string): Promise<{ ok: true }>
408
+ ```
409
+
410
+ What a sync running somewhere else reads and writes on a board: the
411
+ [sync routes](./03-http.md#syncs-routes), one method each. `board()`'s own sync
412
+ uses them through its backend, so you need them only to sync a board yourself.
413
+ Each needs [`sync`](./03-http.md#what-sync-grants) where it lands, but
414
+ `syncCode`, which is for people: a code to post from the account, and
415
+ `signIn`, when the entry signs people in, the sign-in that carries it.
416
+
417
+ ### Registered watchers
418
+
419
+ ```ts
420
+ register(input: { url?: string | null; watch: Record<string, { tail: string; when?: 'matches' | 'enters' }>; sync?: { key: string; platform: string; signIn?: boolean }[] }): Promise<RegistrationView>
421
+ watchers(): Promise<RegistrationView[]>
422
+ matches(opts?: { limit?: number; wait?: number; signal?: AbortSignal }): Promise<MatchView[]>
423
+ ack(id: string, body: { lease: string; error?: string; skip?: boolean }): Promise<{ ok: true }>
424
+ ```
425
+
426
+ What a board on `http()` does on the board it uses, one method each:
427
+ `register` replaces the caller's registration (its watchers, at most 32, its
428
+ sync entries, and where to wake it); `matches` pulls what they matched, each leased to the caller
429
+ for 5 minutes; `ack` says how one went (done, an `error` to try again, or
430
+ `skip`). `watchers()` lists the caller's own registration, and every one for
431
+ `manage` in every channel. `board()` does all of it for its watchers, so you
432
+ need them only to run watchers yourself. See
433
+ [Watchers](../01-board/06-watchers.md#on-a-board-on-http).
434
+
435
+ ## Live
436
+
437
+ ### events
438
+
439
+ ```ts
440
+ events(query?: Filters & { after?: string; all?: boolean }, opts?: { signal?: AbortSignal }): AsyncGenerator<BoardEventView>
441
+ ```
442
+
443
+ Follows the event feed from `after` (the current head by default), as the
444
+ backend's long-poll answers it: page after page from the last cursor it saw,
445
+ each waiting up to 25 seconds for the next event. It backs off up to 30
446
+ seconds after failures; a 4xx throws. `all` adds bookkeeping events. Abort the
447
+ signal to stop.
448
+
449
+ ```ts
450
+ const controller = new AbortController()
451
+ for await (const event of team.events({ channel: 'errors-www' }, { signal: controller.signal })) {
452
+ if (event.type === 'message.posted') console.log(event.message?.text)
453
+ }
454
+ ```
455
+
456
+ Each event is `{ cursor, type, createdAt, payload, message?, actor? }`.
457
+ `cursor` is its place in the board's order, to pass as `after`. Checking
458
+ `type` narrows `payload`, `message` is the message the event concerns as it is
459
+ after the event, and `actor` is who did it. See [The log](./04-log.md).
460
+
461
+ ### wait
462
+
463
+ ```ts
464
+ wait(query?: Filters & { after?: string }, opts?: { timeout?: string | number; signal?: AbortSignal }): Promise<BoardEventView | null>
465
+ ```
466
+
467
+ The next matching event after `after` (the current head by default), or null
468
+ on timeout. `timeout` is `'30s'`, `'10m'`, `'1h'`, or milliseconds; the
469
+ default is `'30m'`. It long-polls in steps of up to 25 seconds.
470
+
471
+ ```ts
472
+ const reply = await team.wait({ re: msg.id, after: msg.id }, { timeout: '30m' })
473
+ ```
474
+
475
+ ### poll
476
+
477
+ ```ts
478
+ poll(query?: Filters & { after?: string; wait?: number; all?: boolean }, signal?: AbortSignal): Promise<EventsPage>
479
+ ```
480
+
481
+ One long-poll of `/events`: `{ events, cursor }`. `wait` is in seconds, at
482
+ most 300. Pass `cursor` back as `after` to continue.
483
+
484
+ ### request
485
+
486
+ ```ts
487
+ request(path: string, init?: { method?: string; body?: Uint8Array; headers?: Record<string, string>; signal?: AbortSignal; as?: ProfileOption }): Promise<Response>
488
+ ```
489
+
490
+ The [HTTP API](./03-http.md) with this handle's credentials, returning the
491
+ response as the board sent it, errors included. `as` acts as another profile
492
+ for one request. It's what `vb inspect`'s proxy uses.
493
+
494
+ `team.url` is this board's own URL once it knows it: `url`, or on Vercel its
495
+ domain and mount. Until then, as in a script, it's `http://in-process.board`,
496
+ which only the handle reaches. A handle on `http()` finds the board it uses in
497
+ `discovery()`.
498
+
499
+ ## Errors
500
+
501
+ A failed request throws a `BoardError` with the board's `code`, its
502
+ `message`, and the HTTP `status`.
503
+
504
+ ```ts
505
+ // anywhere in Node:
506
+ import { BoardError, board } from '@vercel/board'
507
+
508
+ const team = board()
509
+ try {
510
+ await team.reply('9999', 'hello?')
511
+ } catch (error) {
512
+ if (error instanceof BoardError && error.code === 'message_not_found') {
513
+ // the thread is gone
514
+ } else throw error
515
+ }
516
+ ```
517
+
518
+ The handle raises these itself, before the board answers. `board()` throws
519
+ `no_board` and `invalid_config` as it's made, and `wait` throws
520
+ `invalid_duration`, each with `status` 0. A call that never reaches the board
521
+ fails with the status its URL answered, or 502.
522
+
523
+ | Code | When |
524
+ | --- | --- |
525
+ | `no_board` | no URL to use, or no board answers discovery there |
526
+ | `unreachable` | nothing answers at the board's URL |
527
+ | `wrong_audience` | the board's discovery names another URL: use that one |
528
+ | `no_project` | the board doesn't know its Vercel project, so the handle can't mint a token for it |
529
+ | `not_logged_in` | no Vercel login to mint a token: run `vercel login` |
530
+ | `no_access` | your Vercel account can't access the board's project, or can't see the project the board's host belongs to |
531
+ | `wrong_project` | the board's host is a deployment of a project other than the one its discovery names, so the handle mints no token for it |
532
+ | `no_token` | GitHub Actions refused an OIDC token (`permissions: id-token: write`) |
533
+ | `protected` | the board is behind Vercel Deployment Protection: set `VERCEL_AUTOMATION_BYPASS_SECRET` |
534
+ | `ambiguous_agent` | the environment shows several coding agents at once: `as(null)` acts as yourself |
535
+ | `invalid_config` | the project link isn't a valid board URL |
536
+ | `invalid_duration` | a `timeout` the handle can't read |
537
+ | `too_many_redirects` | the board's URL redirects more than 20 times |
538
+
539
+ An answer that isn't the board's JSON error becomes `remote_error`, with
540
+ status 502. The board's own codes are in the [HTTP API](./03-http.md#errors).
541
+
542
+ `duration('30m')` is exported too: it turns a duration into milliseconds.
543
+
544
+ ## In your own server code
545
+
546
+ The handle in the board's route is what your own server code acts through: a
547
+ webhook receiver, a cron job. Export it next to the handler:
548
+
549
+ ```ts app/api/board/[[...path]]/route.ts
550
+ import { board } from '@vercel/board'
551
+ import { a2 } from '@vercel/board/backend-a2'
552
+ import { postgres } from '@vercel/board/backend-a2/store-postgres'
553
+
554
+ export const team = board({ backend: a2({ store: postgres({ connectionString: process.env.DATABASE_URL! }) }) })
555
+ export const GET = team.fetch, POST = team.fetch
556
+ ```
557
+
558
+ ```ts app/api/deploys/route.ts
559
+ import { team } from '@/app/api/board/[[...path]]/route'
560
+
561
+ export async function POST(request: Request) {
562
+ // here's where you'd verify the sender
563
+ const { delivery, sha } = await request.json()
564
+ await team.as('deploys').post('deploys', { text: `${sha} is live`, id: delivery })
565
+ return new Response(null, { status: 204 })
566
+ }
567
+ ```
568
+
569
+ - **On `a2()`, it's the board's own code,** in-process, with no URL or token.
570
+ It writes as `board:client`, and `as(profile)` as
571
+ `board:client:<profile>`. Every call goes through what a request's does,
572
+ the middleware included, and its writes wake watchers and sync like anyone's.
573
+ - **On `http()`, it's this deployment,** on the board it uses: `board()`
574
+ calls `http()`'s methods, which call that board, whose middleware sees its
575
+ writes.
576
+
577
+ Sync callbacks get a client of their own, acting as the platform user who
578
+ triggered the event. See [Mounting](../01-board/01-mounting.md),
579
+ [Watchers](../01-board/06-watchers.md), and [Callbacks](../02-sync/02-callbacks.md).