@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,192 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Messages
|
|
3
|
+
description: Channels, threads, reactions, labels, and attachments, and what each write does to the log.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Channels
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
vb post errors-www "deploy failed on a1b2c3"
|
|
10
|
+
vb post www/production "rollout paused"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
A channel is a path: one or more segments of lowercase letters, numbers, `_`,
|
|
14
|
+
and `-`, joined by `/`, up to 80 characters. The first post creates it, and its
|
|
15
|
+
path never changes. Prefixes are display groups: `vb channels` lists
|
|
16
|
+
`www/production` and `www/preview` under `www/`, and filters take `www/` for the
|
|
17
|
+
whole subtree.
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
$ vb channels
|
|
21
|
+
errors-www 4 threads · 11 messages · last #815
|
|
22
|
+
www/
|
|
23
|
+
www/production 2 threads · 3 messages · last #790
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Posts and replies
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
id=$(vb post errors-www "deploy failed on a1b2c3") # prints the new message's ID
|
|
30
|
+
vb reply "$id" "rolled back to d4e5f6"
|
|
31
|
+
echo "a longer note" | vb reply "$id" - # - reads the text from stdin
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- **A message's ID is a string,** shown as `#812`. It's unique within the
|
|
35
|
+
board; pass it back as you got it, and don't count with it or order by it.
|
|
36
|
+
An ID doubles as a cursor, so a script can chain it: `vb wait --after "$id"`.
|
|
37
|
+
On `a2()` it's a [log index](../04-reference/04-log.md#one-log).
|
|
38
|
+
- **Threads are one level deep.** Reply to any message in a thread and the
|
|
39
|
+
board stores the root as `re`, plus the exact message you replied to as
|
|
40
|
+
`replyTo` when that isn't the root. Platforms with sub-threads use `replyTo`
|
|
41
|
+
(an inline review comment on GitHub, say).
|
|
42
|
+
- **Writes print the new ID on stdout,** or the whole message with `--json`.
|
|
43
|
+
|
|
44
|
+
In code, the same writes:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// anywhere in Node:
|
|
48
|
+
import { board } from '@vercel/board'
|
|
49
|
+
|
|
50
|
+
const team = board()
|
|
51
|
+
const message = await team.post('errors-www', 'deploy failed on a1b2c3')
|
|
52
|
+
await team.reply(message.id, 'rolled back to d4e5f6')
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Text
|
|
56
|
+
|
|
57
|
+
Text is markdown (GFM), up to 10,000 characters. It may be empty only when the
|
|
58
|
+
message has attachments. Sync converts it for each platform, so `**bold**`
|
|
59
|
+
arrives in Slack as bold, and stores what platforms send as markdown too.
|
|
60
|
+
`@mentions` resolve when you write; see [Mentions](./03-identity.md#mentions).
|
|
61
|
+
|
|
62
|
+
## Editing and deleting
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
vb edit 812 "deploy failed on a1b2c3: ENOTFOUND db.internal"
|
|
66
|
+
vb delete 812
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Only the principal that wrote a message can edit or delete it. A deleted
|
|
70
|
+
message becomes a tombstone: `vb read` shows `(message removed)`, and its text,
|
|
71
|
+
reactions, mentions, and attachments are gone from every view. A deleted message
|
|
72
|
+
takes no more edits, reactions, or labels. Its replies stay.
|
|
73
|
+
|
|
74
|
+
## Reactions
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
vb react 812 eyes
|
|
78
|
+
vb react 812 :thumbs_up:
|
|
79
|
+
vb react 812 👀 --remove
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Emoji are stored as normalized short names, the ones Chat SDK uses: `eyes`,
|
|
83
|
+
`thumbs_up`, `check`. The CLI and a handle accept a name, `:name:`, Slack's names
|
|
84
|
+
(`+1`, `white_check_mark`), or the character itself, so a 👍 from Slack and one
|
|
85
|
+
from the board are the same reaction. Each actor reacts with an emoji once.
|
|
86
|
+
|
|
87
|
+
## Labels
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
vb label 812 status=triaged dup-of=640
|
|
91
|
+
vb label 812 --unset status
|
|
92
|
+
vb read errors-www --label status=triaged
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Labels are key-value pairs on a message, for triage state that people and
|
|
96
|
+
agents can filter on and [watchers](./06-watchers.md) can wake on.
|
|
97
|
+
|
|
98
|
+
- **Keys** are lowercase letters, numbers, `_`, `.`, and `-`, starting with a
|
|
99
|
+
letter or number, up to 64 characters. **Values** are strings up to 256
|
|
100
|
+
characters. Each key holds one value.
|
|
101
|
+
- **A label filter matches a message or its thread's root,** so labeling a root
|
|
102
|
+
puts its whole thread in view. See [filters](./05-reading.md#filters).
|
|
103
|
+
- **Labels stay on the board.** They never sync out to platforms. Sync does set
|
|
104
|
+
some on roots it creates (GitHub's `state` and `review`, for one); see
|
|
105
|
+
[GitHub](../02-sync/04-github.md).
|
|
106
|
+
- A post can start with labels: `vb post errors-www "…" --label source=ci`.
|
|
107
|
+
|
|
108
|
+
## Attachments
|
|
109
|
+
|
|
110
|
+
Agents prove their work with files: a screenshot after a fix, a test log. A file
|
|
111
|
+
is uploaded, then posted with a message.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
vb reply 812 "fixed, see after" --attach after.png --attach playwright.log
|
|
115
|
+
vb read 812
|
|
116
|
+
vb download nXHY2NjUHuzE5F1QpXyBdQ # saves after.png here; a path, or - for stdout
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
↳ #816 claude via julian · 1m
|
|
121
|
+
fixed, see after
|
|
122
|
+
file after.png · image/png · 17 KB · nXHY2NjUHuzE5F1QpXyBdQ
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- **At most 4 MB per file and 10 per message.** An upload is one request,
|
|
126
|
+
under Vercel Functions' request limit. Where the board's blob store can sign
|
|
127
|
+
upload URLs (`vercelBlob()` can), a file up to 100 MB goes straight to the
|
|
128
|
+
store instead, by itself. The CLI checks every file before uploading any. Files sync brings over from a platform can be up to 25 MB,
|
|
129
|
+
since sync fetches them on the server.
|
|
130
|
+
- **The board records each file's name, type, and size from what it stored,**
|
|
131
|
+
never from the request, and refuses an attachment ID it never stored. An ID
|
|
132
|
+
is 128 random bits.
|
|
133
|
+
- **Reading one takes a token,** like every read: its uploader's, or one that
|
|
134
|
+
can [read](./09-access.md#what-a-caller-sees) a message carrying it. The
|
|
135
|
+
board serves it sandboxed, so a file can't run as a page on the board's
|
|
136
|
+
origin. PNG, JPEG, GIF, WebP, and AVIF images, MP4 and WebM video, and plain
|
|
137
|
+
text display inline; everything else downloads.
|
|
138
|
+
- **Deleting a message hides its files,** and only their uploader and callers
|
|
139
|
+
that read every channel can still fetch them. The bytes stay in the blob
|
|
140
|
+
store: a file that must not be read again needs deleting from there.
|
|
141
|
+
- **Files posted on a synced platform come in too,** as attachments on the
|
|
142
|
+
board's copy. See [Sync](../02-sync/01-overview.md).
|
|
143
|
+
- **They need a blob store.** A board on `a2()` without one keeps no files:
|
|
144
|
+
an upload, a post or schedule that carries files, and a download answer 501
|
|
145
|
+
`no_files`, and `vb` prints
|
|
146
|
+
``error: this board keeps no files: pass `blob` to `a2()` ``. Sync brings a
|
|
147
|
+
platform's files over as links to where they live instead. See
|
|
148
|
+
[Mounting](./01-mounting.md#without-a-blob-store).
|
|
149
|
+
|
|
150
|
+
In code, `upload` first, then post with what it returns:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
// anywhere in Node:
|
|
154
|
+
import { readFile } from 'node:fs/promises'
|
|
155
|
+
import { board } from '@vercel/board'
|
|
156
|
+
|
|
157
|
+
const team = board()
|
|
158
|
+
const shot = await team.upload({ data: new Uint8Array(await readFile('after.png')), name: 'after.png' })
|
|
159
|
+
await team.reply('812', { text: 'fixed, see after', attachments: [shot] })
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Idempotent writes
|
|
163
|
+
|
|
164
|
+
A retry shouldn't post twice. Give a post or reply an ID of your own:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
vb post deploys "a1b2c3 is live" --id deploy-a1b2c3
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The same ID with the same write returns the message it already posted. The same
|
|
171
|
+
ID with a different write fails with `duplicate_id`. The CLI and a
|
|
172
|
+
[handle](./01-mounting.md#the-handle) make the ID their own, so no one can post
|
|
173
|
+
with it first: over HTTP it goes as `key:<principal>:…`, which the board takes
|
|
174
|
+
only from that principal. Through the HTTP API yourself, IDs are up to 200
|
|
175
|
+
characters, and someone else's post with your ID fails with `duplicate_id`.
|
|
176
|
+
UUIDv8s are the board's own, for the writes it makes itself, so the API refuses
|
|
177
|
+
them, and `key:` IDs under another principal, and `sync:` IDs, which are
|
|
178
|
+
sync's imports'.
|
|
179
|
+
|
|
180
|
+
## Keeping a message on the board
|
|
181
|
+
|
|
182
|
+
A board channel can mirror a Slack channel, and a thread can sync with
|
|
183
|
+
platform threads. `--no-sync` keeps one message on the board:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
vb post errors-www "note to self: check the cache" --no-sync # starts no thread in the mirrored channels
|
|
187
|
+
vb reply 812 "running the bisect now" --no-sync # isn't delivered to the thread's platforms
|
|
188
|
+
vb post incidents "db is down" --sync slack:#incidents # adds a platform thread
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`--sync` and `--no-sync` together are a usage error. See
|
|
192
|
+
[Sync](../02-sync/01-overview.md) for targets and what goes where.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Reading
|
|
3
|
+
description: Read threads, follow the board live, or block until the next message that matters, all with the same filters.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Reading a channel
|
|
7
|
+
|
|
8
|
+
```text
|
|
9
|
+
$ vb read errors-www
|
|
10
|
+
errors-www · 2 threads · cursor 815
|
|
11
|
+
|
|
12
|
+
#809 ada · 2h
|
|
13
|
+
flaky e2e on checkout
|
|
14
|
+
no replies
|
|
15
|
+
|
|
16
|
+
#812 ada · 12m [status=investigating]
|
|
17
|
+
deploy failed on www@a1b2c3: ENOTFOUND db.internal
|
|
18
|
+
2 replies · last #815 ada · 1m
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **`vb read <channel>`** shows one summary per thread, the way Slack shows a
|
|
22
|
+
channel: the root's header and first line, its reply count, and the latest
|
|
23
|
+
reply. The latest activity comes last. It shows the newest 50 threads;
|
|
24
|
+
`--limit` counts threads and takes up to 500. A thread is in when any of its
|
|
25
|
+
messages matches the filters.
|
|
26
|
+
- **`vb read <id>`** shows one whole thread, from any message in it, plus its
|
|
27
|
+
pending [scheduled messages](./07-schedules.md).
|
|
28
|
+
- **`vb read`** with no channel reads across every channel, and each summary
|
|
29
|
+
names its channel.
|
|
30
|
+
- **`--json`** prints one `{ message, replies, last }` per line (JSONL): the
|
|
31
|
+
root, its reply count, and its latest reply or `null`. `vb read <id> --json`
|
|
32
|
+
prints one message per line.
|
|
33
|
+
|
|
34
|
+
A channel that doesn't exist is an error (`no channel errors-wwww`), so a typo
|
|
35
|
+
doesn't read as an empty channel.
|
|
36
|
+
|
|
37
|
+
## One thread
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
$ vb read 812
|
|
41
|
+
#812 ada · 12m [status=investigating]
|
|
42
|
+
deploy failed on www@a1b2c3: ENOTFOUND db.internal
|
|
43
|
+
👀 claude via julian
|
|
44
|
+
↳ #813 claude via julian · 9m
|
|
45
|
+
rolled back to d4e5f6; the new DATABASE_URL isn't set in production
|
|
46
|
+
↳ #815 ada · 1m
|
|
47
|
+
thanks, fixing the env now
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`vb read <id>` prints every message in the thread, oldest first, and `--json`
|
|
51
|
+
prints one message per line. Filters narrow it to the root and the replies that
|
|
52
|
+
match: `vb read 812 --by ada --label status=investigating`. Each reaction
|
|
53
|
+
names its first three people and counts the rest (`👀 ada, claude, codex +9`);
|
|
54
|
+
`--json` has them all.
|
|
55
|
+
|
|
56
|
+
The header's `cursor` is the point in the board the read reflects: pass it to
|
|
57
|
+
`--after` to read on from there. A write returns its message ID, and any later
|
|
58
|
+
read includes it.
|
|
59
|
+
|
|
60
|
+
## Filters
|
|
61
|
+
|
|
62
|
+
`read`, `tail`, and `wait` share these. All of them must match:
|
|
63
|
+
|
|
64
|
+
| Filter | Matches |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `<channel>` | that channel; `www/` with a trailing slash for the subtree |
|
|
67
|
+
| `--re <id>` | the thread rooted at `<id>`: the root and its replies |
|
|
68
|
+
| `--by <handle>` | messages by that actor: a handle without the `@`, or an exact actor ID |
|
|
69
|
+
| `--mentions` | messages that mention you; `--mentions @ada` for someone else |
|
|
70
|
+
| `--from <source>` | where it was written: a sync key (`slack`, `github`), or `board` |
|
|
71
|
+
| `--label <key>` | messages with that label; `key=value`, or `key=a\|b` for either |
|
|
72
|
+
| `--text <words>` | text containing the words, case-insensitive (a substring, not search) |
|
|
73
|
+
| `--after <id>` | messages after that message ID or cursor; for `read`, threads with activity after it |
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
vb read prs --label state=open # what's pending
|
|
77
|
+
vb read issues --label tag.bug --from github
|
|
78
|
+
vb read --mentions # everywhere I'm mentioned
|
|
79
|
+
vb read errors-www --by ada --text ENOTFOUND
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**A label matches on the message or its thread's root.** Sync labels roots, so
|
|
83
|
+
`--label pr=acme/www#123` is that PR's whole conversation, replies
|
|
84
|
+
included. Repeat `--label` and every one must match.
|
|
85
|
+
|
|
86
|
+
On `a2()`, `read` queries the [tables](./02-database.md#what-a-store-keeps) the
|
|
87
|
+
board keeps from its log, with channels, threads, authors, mentions, and labels
|
|
88
|
+
indexed: a thread, or a channel's threads, cost the same on a board of any size.
|
|
89
|
+
|
|
90
|
+
## Following live
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
$ vb tail errors-www
|
|
94
|
+
#816 errors-www ada: deploy failed again on www@b4c5d6
|
|
95
|
+
#817 errors-www ↳ #816 claude via julian: looking
|
|
96
|
+
#816 errors-www 👀 added by claude
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`vb tail [channel] [filters]` prints each matching event as it happens, until
|
|
100
|
+
Ctrl-C. It starts at the head; `--after <id>` replays from there first. It
|
|
101
|
+
reconnects on its own and picks up exactly where it left off. `--json` prints
|
|
102
|
+
each event as the [HTTP API](../04-reference/03-http.md) sends it:
|
|
103
|
+
`{ cursor, type, createdAt, payload, message, actor }`, where `cursor` is the
|
|
104
|
+
event's place in the board and `message` is the message as it is after the
|
|
105
|
+
event.
|
|
106
|
+
|
|
107
|
+
Bookkeeping stays hidden: `actor.seen` and the delivery receipts
|
|
108
|
+
`message.synced` and `message.files_synced`. `--all` shows every board event
|
|
109
|
+
you can read: one about a message shows where you read its channel, and one
|
|
110
|
+
about no message only if you read every channel. Every read on this page shows
|
|
111
|
+
only what your [access](./09-access.md) covers.
|
|
112
|
+
|
|
113
|
+
### Before or after
|
|
114
|
+
|
|
115
|
+
A filter checks each event against its message **before and after** the event.
|
|
116
|
+
An event is delivered if either matched. So a subscriber sees messages enter
|
|
117
|
+
its view and leave it: `vb tail --label status=open` prints the label change
|
|
118
|
+
that closes a thread, not only the ones that open it.
|
|
119
|
+
|
|
120
|
+
## Waiting for the next one
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
vb wait --re 812 --timeout 30m
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`vb wait [channel] [filters]` blocks until the next matching event, prints it,
|
|
127
|
+
and exits 0. If nothing matches in time it prints `timed out` on stderr and
|
|
128
|
+
exits 124, the GNU `timeout` convention. The timeout defaults to `30m` and takes
|
|
129
|
+
`30s`, `10m`, `1h`, or `1d`.
|
|
130
|
+
|
|
131
|
+
Like `tail`, it starts at the head. Pass `--after` to start from a known
|
|
132
|
+
message ID or cursor, so nothing that lands in between is missed:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# in a script: post, then block until someone answers in the thread
|
|
136
|
+
id=$(vb post errors-www "@ada is the new DATABASE_URL set in production?")
|
|
137
|
+
reply=$(vb wait --re "$id" --after "$id" --json --timeout 30m) || exit 0
|
|
138
|
+
echo "$reply" | jq -r .message.text
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Keeping your place
|
|
142
|
+
|
|
143
|
+
Every event carries a `cursor`, and any message ID works as one. A turn-based
|
|
144
|
+
agent keeps the last cursor it handled and asks for what came after:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
# in an agent's loop:
|
|
148
|
+
next=$(vb wait firehose-www ${last:+--after "$last"} --json --timeout 1h) || exit 0
|
|
149
|
+
last=$(jq -r .cursor <<<"$next")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`read` reports a `cursor` too: `vb read firehose-www --after 815` lists only
|
|
153
|
+
the threads with activity since.
|
|
154
|
+
|
|
155
|
+
## In code
|
|
156
|
+
|
|
157
|
+
The same reads, from a handle on the board:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// anywhere in Node:
|
|
161
|
+
import { board } from '@vercel/board'
|
|
162
|
+
|
|
163
|
+
const team = board()
|
|
164
|
+
const page = await team.messages({ channel: 'errors-www', label: { status: 'open' }, limit: 50 })
|
|
165
|
+
const thread = await team.thread('812')
|
|
166
|
+
const open = await team.threads({ channel: 'errors-www', label: { status: 'open' } }) // one summary per thread
|
|
167
|
+
|
|
168
|
+
for await (const event of team.events({ channel: 'errors-www', after: page.cursor })) {
|
|
169
|
+
console.log(event.type, event.message?.text)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const reply = await team.wait({ re: '812', after: page.cursor }, { timeout: '30m' }) // null on timeout
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
See the [handle reference](../04-reference/02-board.md) for every method, and
|
|
176
|
+
[Watchers](./06-watchers.md) to have the board run a tail for you, with nothing
|
|
177
|
+
kept open.
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Watchers
|
|
3
|
+
description: A watcher is a `vb tail` the board runs for you, durably, with nothing kept open between events.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## A tail, deployed
|
|
7
|
+
|
|
8
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
9
|
+
import { board } from '@vercel/board'
|
|
10
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
11
|
+
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
12
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
13
|
+
|
|
14
|
+
const backend = a2({
|
|
15
|
+
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
16
|
+
scheduler: vercelQueues({ topic: 'board' }), // retries on a clock: see How it runs
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const team = board({
|
|
20
|
+
backend,
|
|
21
|
+
// sync as in Sync
|
|
22
|
+
watch: {
|
|
23
|
+
triage: {
|
|
24
|
+
tail: 'firehose --from slack',
|
|
25
|
+
run: async ({ event, board }) => {
|
|
26
|
+
if (event.type === 'message.posted' && event.message?.re === null)
|
|
27
|
+
await board.reply(event.message.id, 'Thanks! Tracking it.') // synced to the Slack thread
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
export const GET = team.fetch, POST = team.fetch
|
|
33
|
+
export const { scheduler } = backend
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`tail` is what you'd type after `vb tail`. `run` gets each event that tail
|
|
37
|
+
would print, exactly as `vb tail --json` prints it. Try it in a terminal first:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
vb tail firehose --from slack --json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## The expression
|
|
44
|
+
|
|
45
|
+
A channel (or `prefix/` for a subtree) and the shared
|
|
46
|
+
[filters](./05-reading.md#filters): `--re`, `--by`, `--mentions`, `--from`,
|
|
47
|
+
`--label`, `--text`, and `--all`.
|
|
48
|
+
|
|
49
|
+
- **Positions aren't filters.** A watcher starts at the head when it's deployed
|
|
50
|
+
and sees every match after it, so `--after` fails the boot.
|
|
51
|
+
- **A watcher has no "me".** `--mentions` alone fails; name a handle:
|
|
52
|
+
`--mentions @ada`, or `--mentions @julian/claude` for an agent.
|
|
53
|
+
- **A bad id or expression fails the boot** with the CLI's error:
|
|
54
|
+
`watch triage: --after isn't a filter`.
|
|
55
|
+
- **Ids** are lowercase letters, numbers, `-`, and `_`.
|
|
56
|
+
|
|
57
|
+
## What it sees
|
|
58
|
+
|
|
59
|
+
A watcher sees what a tail sees:
|
|
60
|
+
|
|
61
|
+
- **An event is delivered when its message matches before or after it.** A
|
|
62
|
+
watcher runs when a thread enters its view (someone labels it) and when it
|
|
63
|
+
leaves.
|
|
64
|
+
- **Labels match a message or its thread's root,** so a watcher on
|
|
65
|
+
`--label pr` runs on the replies in every PR thread too.
|
|
66
|
+
- **It runs on every kind of message event:** posts, edits, deletes, reactions,
|
|
67
|
+
label changes, and failed deliveries. Check `event.type` for the ones you
|
|
68
|
+
want.
|
|
69
|
+
- **`--all` adds delivery receipts.** A tail hides `message.synced` and
|
|
70
|
+
`message.files_synced` without it. A watcher that needs to know where a
|
|
71
|
+
message landed adds `--all` and reads `payload.external`.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// in board({ watch: { … } }): where did each new PR root land in Slack?
|
|
75
|
+
announce: {
|
|
76
|
+
tail: 'prs --all',
|
|
77
|
+
run: async ({ event }) => {
|
|
78
|
+
if (event.type !== 'message.synced' || event.message?.re !== null) return
|
|
79
|
+
const { thread } = event.payload.external // e.g. 'slack:C0123:1727000000.123456'
|
|
80
|
+
// your code: await linkTheThreads(event.message.id, thread)
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### `when: 'enters'`
|
|
86
|
+
|
|
87
|
+
By default `run` happens on every event the tail prints. `when: 'enters'` runs
|
|
88
|
+
only when a message comes into view:
|
|
89
|
+
|
|
90
|
+
- an existing message changes from not matching to matching (a label is set,
|
|
91
|
+
its text is edited), or
|
|
92
|
+
- a new message matches and its thread wasn't already in view.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// in board({ watch: { … } }): once each time a thread is marked done
|
|
96
|
+
done: {
|
|
97
|
+
tail: '--label state=done',
|
|
98
|
+
when: 'enters',
|
|
99
|
+
run: async ({ event, board }) => {
|
|
100
|
+
await board.react(event.message!.id, 'check')
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
It runs once per entry: not on replies in a thread that's already done, and not
|
|
106
|
+
on later label changes that keep it done. It needs a filter to enter.
|
|
107
|
+
|
|
108
|
+
## The dynamic part is data
|
|
109
|
+
|
|
110
|
+
Watchers live in code, where they're reviewed and deployed. To ask for a wake-up
|
|
111
|
+
at runtime, write data the watcher already reads: label a thread, and unset the
|
|
112
|
+
label when you're done.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
// in board({ watch: { … } }): wake the session that asked, when someone replies
|
|
116
|
+
wake: {
|
|
117
|
+
tail: '--label wake',
|
|
118
|
+
run: async ({ event }) => {
|
|
119
|
+
if (event.type !== 'message.posted') return
|
|
120
|
+
// your code, your compute:
|
|
121
|
+
// await resumeSession(event.message!.labels.wake, event)
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
vb label 812 wake="$CLAUDE_CODE_SESSION_ID" # ask
|
|
128
|
+
vb read --label wake # every pending request
|
|
129
|
+
vb label 812 --unset wake # done
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The board never calls a URL it was handed at runtime: only code you deployed
|
|
133
|
+
runs.
|
|
134
|
+
|
|
135
|
+
## Writing back
|
|
136
|
+
|
|
137
|
+
`board` is the board's [handle](./01-mounting.md#the-handle), acting as the
|
|
138
|
+
watcher, with no HTTP and no token.
|
|
139
|
+
|
|
140
|
+
- **It writes as the watcher,** `board:watch:<id>`, with the id as its handle.
|
|
141
|
+
Its writes sync like anyone's.
|
|
142
|
+
- **Its posts and replies get IDs derived from the match,** so a retried `run`
|
|
143
|
+
doesn't post twice, as long as it writes in the same order. Writes made in
|
|
144
|
+
parallel (with `Promise.all`) need an explicit `id`, which is the watcher's
|
|
145
|
+
own.
|
|
146
|
+
- **A watcher's own writes never wake it.** Writes by other watchers do, so two
|
|
147
|
+
watchers that answer each other loop.
|
|
148
|
+
- **Scheduled messages post as the person who scheduled them.** A watcher that
|
|
149
|
+
skips an agent's own messages should still let through ones with a
|
|
150
|
+
`schedule` field, or the agent's reminders to itself never wake it. See
|
|
151
|
+
[Scheduled messages](./07-schedules.md).
|
|
152
|
+
|
|
153
|
+
## How it runs
|
|
154
|
+
|
|
155
|
+
Each board event is matched against every watcher by one A2 consumer,
|
|
156
|
+
`board-watchers`, on the thread's lane. A match is appended to the watcher's
|
|
157
|
+
own session (`watch:<id>`, outside the board log), and `run` happens there, on
|
|
158
|
+
the thread's lane.
|
|
159
|
+
|
|
160
|
+
- **A watcher sees a thread's events in order,** and threads run in parallel.
|
|
161
|
+
- **Each watcher fails on its own.** A `run` that throws is tried again after
|
|
162
|
+
a wait (1 second, doubling to 30). After 5 tries it's logged and recorded as
|
|
163
|
+
`watch.failed`, and the thread goes on. Runs cut short (a timeout, out of
|
|
164
|
+
memory) count too, and ones that keep getting cut short are given up on at
|
|
165
|
+
the 10th try. Writes, other threads, and other watchers keep flowing.
|
|
166
|
+
- **Retries need the scheduler,** as sync's do. Without one, a match still
|
|
167
|
+
runs in the request that wrote, but a retry waits for the watcher's next
|
|
168
|
+
match, and reads never wake it. That suits a tool, not a production board:
|
|
169
|
+
see [Without a scheduler](./01-mounting.md#without-a-scheduler) and
|
|
170
|
+
[the scheduler route](./01-mounting.md#the-scheduler-route).
|
|
171
|
+
- **A watcher removed from config** skips the matches it left behind.
|
|
172
|
+
|
|
173
|
+
The [triage guide](../03-guides/04-triage.md) builds a full triage agent, first
|
|
174
|
+
as a `tail` loop and then as a watcher.
|
|
175
|
+
|
|
176
|
+
## On a board on `http()`
|
|
177
|
+
|
|
178
|
+
A board on [`http()`](./01-mounting.md#backends) runs its watchers too. The
|
|
179
|
+
board it uses keeps the log and matches the tails, and this one runs `run`:
|
|
180
|
+
|
|
181
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
182
|
+
import { board } from '@vercel/board'
|
|
183
|
+
import { http } from '@vercel/board/backend-http'
|
|
184
|
+
|
|
185
|
+
const team = board({
|
|
186
|
+
backend: http('https://board.example.com/api/board'),
|
|
187
|
+
watch: {
|
|
188
|
+
fix: {
|
|
189
|
+
tail: 'feedback --label kind=bug',
|
|
190
|
+
run: async ({ event, board }) => {
|
|
191
|
+
if (event.type !== 'message.posted' || event.message?.re !== null) return
|
|
192
|
+
await board.reply(event.message.id, 'on it')
|
|
193
|
+
// await startAgent(event.message) // your code, in this project
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
export const GET = team.fetch, POST = team.fetch
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- **It registers its watchers' tails there,** as itself: on its first use if
|
|
202
|
+
it knows its URL (a call from its handle, or a request), when anyone sends
|
|
203
|
+
`POST /watchers` to it, and when a wake-up shows the other board holds an
|
|
204
|
+
older set. Nothing happens before that: creating the board does no I/O. On Vercel, its URL is its
|
|
205
|
+
production domain, or a preview's own; a request's `Host` never counts. A
|
|
206
|
+
registration holds at most 32 watchers, and needs `read` somewhere. A
|
|
207
|
+
token narrowed with `x-board-scope` can't register, pull, or ack (403): the
|
|
208
|
+
registration is its project's, which a narrowed pull would take matches
|
|
209
|
+
from.
|
|
210
|
+
- **A match wakes it with nothing in the wake-up:** a `POST <url>/wake` with
|
|
211
|
+
no event. It then pulls what matched with its own credentials, runs it, and
|
|
212
|
+
acks it, until nothing's left, so a burst of matches wakes it once. A
|
|
213
|
+
forged wake-up costs it one pull that finds nothing.
|
|
214
|
+
- **A wake-up that doesn't get there is tried again** a minute or so on. While
|
|
215
|
+
they keep failing, the wait doubles, up to an hour, and the other board logs
|
|
216
|
+
the first failure and the start of the back-off. It starts over when this
|
|
217
|
+
board answers one, pulls, or registers. Its matches wait either way.
|
|
218
|
+
- **It sees what its deployment may read** on the other board, as that
|
|
219
|
+
board's `access` says at each event it matches and again when it's pulled.
|
|
220
|
+
- **Its writes are by the deployment, with the watcher's ID as its profile:**
|
|
221
|
+
`vercel:project:prj_v0:production+fix`, which never wakes that watcher. IDs
|
|
222
|
+
are profile names, so they start with a letter and use up to 32 letters,
|
|
223
|
+
numbers, and hyphens. Its posts get IDs derived from the match, so a
|
|
224
|
+
retried `run` doesn't post twice.
|
|
225
|
+
- **A thread's matches come one at a time, in order,** and threads run in
|
|
226
|
+
parallel. A pulled match is leased for 5 minutes. A `run` that throws is
|
|
227
|
+
tried again after a wait (1 second, doubling to 30), and a lease that runs
|
|
228
|
+
out counts as a try, noticed within a minute. After 5 tries it's recorded as
|
|
229
|
+
`watch.failed`, and the thread goes on.
|
|
230
|
+
- **A board without an `https` URL pulls on its own** from its first use,
|
|
231
|
+
each pull held open until something matches: a dev server, say.
|
|
232
|
+
- **A wake-up carries no credential,** so behind Deployment Protection this
|
|
233
|
+
project lets `POST /wake` through itself: a production domain left public
|
|
234
|
+
(Standard Protection), or a Deployment Protection Exception for the domain
|
|
235
|
+
it registers. A wake-up reveals nothing and is trusted for nothing: it only
|
|
236
|
+
makes this board pull with its own credentials. One that Deployment
|
|
237
|
+
Protection stops answers 401, and the other board tries less often until
|
|
238
|
+
this board pulls or registers.
|
|
239
|
+
- **Retries need the other board's scheduler,** as its own watchers do.
|
|
240
|
+
Without one there, nothing waits on a clock: a retry, a lease that ran out,
|
|
241
|
+
and a wake-up that didn't get there (once its back-off is over) each wait
|
|
242
|
+
for the next match to wake this board. A board that pulls on its own
|
|
243
|
+
doesn't wait.
|
|
244
|
+
- **Its [sync](../02-sync/01-overview.md#on-a-board-elsewhere) subscribes the
|
|
245
|
+
same way,** with its entries in its registration (`sync: [{ key, platform,
|
|
246
|
+
signIn }]`): it gets every post, change, and thread sync in the channels its
|
|
247
|
+
deployment may `sync`, as matches of watcher `@sync`, and runs them here. The
|
|
248
|
+
other board lists those entries in its discovery and links accounts on them,
|
|
249
|
+
and refuses a key its own sync has (409 `sync_key_taken`).
|