@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.
- package/README.md +180 -0
- package/dist/a2-5EG8FkZ8.d.mts +3359 -0
- package/dist/a2-DYGYankn.mjs +3045 -0
- package/dist/access-CJU2-vaa.mjs +226 -0
- package/dist/access-CV96SYgr.d.mts +32 -0
- package/dist/access.d.mts +29 -0
- package/dist/access.mjs +4 -0
- package/dist/actors-rdN4dDTU.mjs +223 -0
- package/dist/attempts-BQUp9MKO.mjs +919 -0
- package/dist/auth-COzc09sn.mjs +276 -0
- package/dist/auth-wHFcAte3.d.mts +203 -0
- package/dist/backend-a2/blob-vercel.d.mts +8 -0
- package/dist/backend-a2/blob-vercel.mjs +81 -0
- package/dist/backend-a2/contract.d.mts +18 -0
- package/dist/backend-a2/contract.mjs +5 -0
- package/dist/backend-a2/scheduler-local.d.mts +11 -0
- package/dist/backend-a2/scheduler-local.mjs +2 -0
- package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
- package/dist/backend-a2/scheduler-qstash.mjs +2 -0
- package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
- package/dist/backend-a2/scheduler-vercel.mjs +2 -0
- package/dist/backend-a2/store-memory.d.mts +11 -0
- package/dist/backend-a2/store-memory.mjs +19 -0
- package/dist/backend-a2/store-postgres.d.mts +38 -0
- package/dist/backend-a2/store-postgres.mjs +110 -0
- package/dist/backend-a2/store-redis-http.d.mts +17 -0
- package/dist/backend-a2/store-redis-http.mjs +32 -0
- package/dist/backend-a2/store-redis.d.mts +23 -0
- package/dist/backend-a2/store-redis.mjs +39 -0
- package/dist/backend-a2/store-sqlite.d.mts +12 -0
- package/dist/backend-a2/store-sqlite.mjs +31 -0
- package/dist/backend-a2.d.mts +4 -0
- package/dist/backend-a2.mjs +2 -0
- package/dist/backend-http-8ex5g7Wv.d.mts +630 -0
- package/dist/backend-http-BsDSgmeo.mjs +613 -0
- package/dist/backend-http-Cv6xbEy9.mjs +469 -0
- package/dist/backend-http.d.mts +10 -0
- package/dist/backend-http.mjs +2 -0
- package/dist/backend-http.node.d.mts +10 -0
- package/dist/backend-http.node.mjs +2 -0
- package/dist/blob-C0J3wbGv.d.mts +39 -0
- package/dist/bots-CGkO76qH.mjs +67 -0
- package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
- package/dist/chat-provider-jFSeyNmB.mjs +389 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2252 -0
- package/dist/client-DGDPaN7U.mjs +380 -0
- package/dist/conformance-DRR23YNy.mjs +512 -0
- package/dist/conformance.d.mts +30 -0
- package/dist/conformance.mjs +2 -0
- package/dist/contract-CuJmlrhw.mjs +566 -0
- package/dist/emoji-CTLpvH-A.mjs +445 -0
- package/dist/engine-3EvQ8idk.mjs +1449 -0
- package/dist/errors-DX3hpwWt.mjs +64 -0
- package/dist/exports-BpgyEdh6.d.mts +4 -0
- package/dist/exports-CyY3_FjZ.mjs +1285 -0
- package/dist/files-SniSmeEr.mjs +47 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.default-BZ3lItjH.mjs +12 -0
- package/dist/index.mjs +19 -0
- package/dist/index.node.d.mts +14 -0
- package/dist/index.node.mjs +5 -0
- package/dist/inspect/app.js +416 -0
- package/dist/inspect/engine.js +5 -0
- package/dist/local-Cr_dSjvs.mjs +44 -0
- package/dist/own-OwgPWdmh.mjs +15 -0
- package/dist/project-CFiArraX.mjs +124 -0
- package/dist/provider-BGjz-mDa.d.mts +2473 -0
- package/dist/provider-CuewcDNZ.mjs +205 -0
- package/dist/redis-tables-B35vv4CZ.mjs +842 -0
- package/dist/reducers-B_vUGQRp.d.mts +1172 -0
- package/dist/reducers-mUPFuFJV.mjs +293 -0
- package/dist/schemas-Be08nyfW.mjs +74 -0
- package/dist/schemas-CWxs3ECJ.d.mts +50 -0
- package/dist/serve-Ccbb2LSb.mjs +170 -0
- package/dist/server-Cxey38B-.mjs +17 -0
- package/dist/serving-BNbzc96L.mjs +2 -0
- package/dist/sql-DbiEnqM0.mjs +571 -0
- package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
- package/dist/sync-github.d.mts +473 -0
- package/dist/sync-github.mjs +603 -0
- package/dist/sync-kapso.d.mts +175 -0
- package/dist/sync-kapso.mjs +86 -0
- package/dist/sync-slack.d.mts +364 -0
- package/dist/sync-slack.mjs +728 -0
- package/dist/sync.chat.d.mts +2 -0
- package/dist/sync.chat.mjs +2 -0
- package/dist/sync.d.mts +3 -0
- package/dist/sync.mjs +4 -0
- package/dist/targets-ojFEXEyN.mjs +94 -0
- package/dist/tokens-DKLn5P9B.mjs +256 -0
- package/dist/types-BHCjTZdB.d.mts +104 -0
- package/dist/users-Dls7_ANV.mjs +42 -0
- package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
- package/dist/watchers-hash-D9hbHLKB.mjs +225 -0
- package/docs/01-board/01-mounting.md +506 -0
- package/docs/01-board/02-database.md +258 -0
- package/docs/01-board/03-identity.md +262 -0
- package/docs/01-board/04-messages.md +192 -0
- package/docs/01-board/05-reading.md +177 -0
- package/docs/01-board/06-watchers.md +249 -0
- package/docs/01-board/07-schedules.md +133 -0
- package/docs/01-board/08-middleware.md +139 -0
- package/docs/01-board/09-access.md +499 -0
- package/docs/01-quickstart.md +196 -0
- package/docs/02-sync/01-overview.md +290 -0
- package/docs/02-sync/02-callbacks.md +265 -0
- package/docs/02-sync/03-slack.md +248 -0
- package/docs/02-sync/04-github.md +201 -0
- package/docs/02-sync/05-whatsapp.md +148 -0
- package/docs/02-sync/06-providers.md +228 -0
- package/docs/03-guides/01-coding-agents.md +226 -0
- package/docs/03-guides/02-inspector.md +104 -0
- package/docs/03-guides/03-ci.md +251 -0
- package/docs/03-guides/04-triage.md +233 -0
- package/docs/03-guides/05-agent-factory.md +142 -0
- package/docs/04-reference/01-cli.md +606 -0
- package/docs/04-reference/02-board.md +579 -0
- package/docs/04-reference/03-http.md +489 -0
- package/docs/04-reference/04-log.md +250 -0
- package/docs/04-reference/05-conformance.md +97 -0
- package/docs/index.md +186 -0
- package/package.json +168 -1
- package/sql/postgres/board.sql +145 -0
- package/sql/postgres/notifications.sql +24 -0
- 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).
|