@vercel/board 0.0.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +180 -0
  2. package/dist/a2-5EG8FkZ8.d.mts +3359 -0
  3. package/dist/a2-DYGYankn.mjs +3045 -0
  4. package/dist/access-CJU2-vaa.mjs +226 -0
  5. package/dist/access-CV96SYgr.d.mts +32 -0
  6. package/dist/access.d.mts +29 -0
  7. package/dist/access.mjs +4 -0
  8. package/dist/actors-rdN4dDTU.mjs +223 -0
  9. package/dist/attempts-BQUp9MKO.mjs +919 -0
  10. package/dist/auth-COzc09sn.mjs +276 -0
  11. package/dist/auth-wHFcAte3.d.mts +203 -0
  12. package/dist/backend-a2/blob-vercel.d.mts +8 -0
  13. package/dist/backend-a2/blob-vercel.mjs +81 -0
  14. package/dist/backend-a2/contract.d.mts +18 -0
  15. package/dist/backend-a2/contract.mjs +5 -0
  16. package/dist/backend-a2/scheduler-local.d.mts +11 -0
  17. package/dist/backend-a2/scheduler-local.mjs +2 -0
  18. package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
  19. package/dist/backend-a2/scheduler-qstash.mjs +2 -0
  20. package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
  21. package/dist/backend-a2/scheduler-vercel.mjs +2 -0
  22. package/dist/backend-a2/store-memory.d.mts +11 -0
  23. package/dist/backend-a2/store-memory.mjs +19 -0
  24. package/dist/backend-a2/store-postgres.d.mts +38 -0
  25. package/dist/backend-a2/store-postgres.mjs +110 -0
  26. package/dist/backend-a2/store-redis-http.d.mts +17 -0
  27. package/dist/backend-a2/store-redis-http.mjs +32 -0
  28. package/dist/backend-a2/store-redis.d.mts +23 -0
  29. package/dist/backend-a2/store-redis.mjs +39 -0
  30. package/dist/backend-a2/store-sqlite.d.mts +12 -0
  31. package/dist/backend-a2/store-sqlite.mjs +31 -0
  32. package/dist/backend-a2.d.mts +4 -0
  33. package/dist/backend-a2.mjs +2 -0
  34. package/dist/backend-http-8ex5g7Wv.d.mts +630 -0
  35. package/dist/backend-http-BsDSgmeo.mjs +613 -0
  36. package/dist/backend-http-Cv6xbEy9.mjs +469 -0
  37. package/dist/backend-http.d.mts +10 -0
  38. package/dist/backend-http.mjs +2 -0
  39. package/dist/backend-http.node.d.mts +10 -0
  40. package/dist/backend-http.node.mjs +2 -0
  41. package/dist/blob-C0J3wbGv.d.mts +39 -0
  42. package/dist/bots-CGkO76qH.mjs +67 -0
  43. package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
  44. package/dist/chat-provider-jFSeyNmB.mjs +389 -0
  45. package/dist/cli.d.mts +1 -0
  46. package/dist/cli.mjs +2252 -0
  47. package/dist/client-DGDPaN7U.mjs +380 -0
  48. package/dist/conformance-DRR23YNy.mjs +512 -0
  49. package/dist/conformance.d.mts +30 -0
  50. package/dist/conformance.mjs +2 -0
  51. package/dist/contract-CuJmlrhw.mjs +566 -0
  52. package/dist/emoji-CTLpvH-A.mjs +445 -0
  53. package/dist/engine-3EvQ8idk.mjs +1449 -0
  54. package/dist/errors-DX3hpwWt.mjs +64 -0
  55. package/dist/exports-BpgyEdh6.d.mts +4 -0
  56. package/dist/exports-CyY3_FjZ.mjs +1285 -0
  57. package/dist/files-SniSmeEr.mjs +47 -0
  58. package/dist/index.d.mts +13 -0
  59. package/dist/index.default-BZ3lItjH.mjs +12 -0
  60. package/dist/index.mjs +19 -0
  61. package/dist/index.node.d.mts +14 -0
  62. package/dist/index.node.mjs +5 -0
  63. package/dist/inspect/app.js +416 -0
  64. package/dist/inspect/engine.js +5 -0
  65. package/dist/local-Cr_dSjvs.mjs +44 -0
  66. package/dist/own-OwgPWdmh.mjs +15 -0
  67. package/dist/project-CFiArraX.mjs +124 -0
  68. package/dist/provider-BGjz-mDa.d.mts +2473 -0
  69. package/dist/provider-CuewcDNZ.mjs +205 -0
  70. package/dist/redis-tables-B35vv4CZ.mjs +842 -0
  71. package/dist/reducers-B_vUGQRp.d.mts +1172 -0
  72. package/dist/reducers-mUPFuFJV.mjs +293 -0
  73. package/dist/schemas-Be08nyfW.mjs +74 -0
  74. package/dist/schemas-CWxs3ECJ.d.mts +50 -0
  75. package/dist/serve-Ccbb2LSb.mjs +170 -0
  76. package/dist/server-Cxey38B-.mjs +17 -0
  77. package/dist/serving-BNbzc96L.mjs +2 -0
  78. package/dist/sql-DbiEnqM0.mjs +571 -0
  79. package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
  80. package/dist/sync-github.d.mts +473 -0
  81. package/dist/sync-github.mjs +603 -0
  82. package/dist/sync-kapso.d.mts +175 -0
  83. package/dist/sync-kapso.mjs +86 -0
  84. package/dist/sync-slack.d.mts +364 -0
  85. package/dist/sync-slack.mjs +728 -0
  86. package/dist/sync.chat.d.mts +2 -0
  87. package/dist/sync.chat.mjs +2 -0
  88. package/dist/sync.d.mts +3 -0
  89. package/dist/sync.mjs +4 -0
  90. package/dist/targets-ojFEXEyN.mjs +94 -0
  91. package/dist/tokens-DKLn5P9B.mjs +256 -0
  92. package/dist/types-BHCjTZdB.d.mts +104 -0
  93. package/dist/users-Dls7_ANV.mjs +42 -0
  94. package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
  95. package/dist/watchers-hash-D9hbHLKB.mjs +225 -0
  96. package/docs/01-board/01-mounting.md +506 -0
  97. package/docs/01-board/02-database.md +258 -0
  98. package/docs/01-board/03-identity.md +262 -0
  99. package/docs/01-board/04-messages.md +192 -0
  100. package/docs/01-board/05-reading.md +177 -0
  101. package/docs/01-board/06-watchers.md +249 -0
  102. package/docs/01-board/07-schedules.md +133 -0
  103. package/docs/01-board/08-middleware.md +139 -0
  104. package/docs/01-board/09-access.md +499 -0
  105. package/docs/01-quickstart.md +196 -0
  106. package/docs/02-sync/01-overview.md +290 -0
  107. package/docs/02-sync/02-callbacks.md +265 -0
  108. package/docs/02-sync/03-slack.md +248 -0
  109. package/docs/02-sync/04-github.md +201 -0
  110. package/docs/02-sync/05-whatsapp.md +148 -0
  111. package/docs/02-sync/06-providers.md +228 -0
  112. package/docs/03-guides/01-coding-agents.md +226 -0
  113. package/docs/03-guides/02-inspector.md +104 -0
  114. package/docs/03-guides/03-ci.md +251 -0
  115. package/docs/03-guides/04-triage.md +233 -0
  116. package/docs/03-guides/05-agent-factory.md +142 -0
  117. package/docs/04-reference/01-cli.md +606 -0
  118. package/docs/04-reference/02-board.md +579 -0
  119. package/docs/04-reference/03-http.md +489 -0
  120. package/docs/04-reference/04-log.md +250 -0
  121. package/docs/04-reference/05-conformance.md +97 -0
  122. package/docs/index.md +186 -0
  123. package/package.json +168 -1
  124. package/sql/postgres/board.sql +145 -0
  125. package/sql/postgres/notifications.sql +24 -0
  126. package/sql/postgres/schema.sql +451 -0
@@ -0,0 +1,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`).