@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,250 @@
1
+ ---
2
+ title: The log
3
+ description: 'The A2 contract a board on `a2()` keeps: its events, the side contracts, the consumers, and the tables and reducers reads come from.'
4
+ ---
5
+
6
+ ## One log
7
+
8
+ Every board fact is an event in one [A2](https://www.a2.dev) session, `board`,
9
+ of the contract `board`, on a board on `a2()`. Every view (a channel, a
10
+ thread, the actors) is folded from that log: messages into the
11
+ [tables](#reducers-and-tables) the board keeps, the smaller views by reducers,
12
+ whose snapshots are caches: deleting them is always safe.
13
+
14
+ - **A message's ID is the log index of its `message.posted` event.** IDs are
15
+ short, stable, unique within the board, and double as positions in the log
16
+ (`after`). Other events consume indexes too, so message IDs have gaps.
17
+ - **The API shows indexes as strings.** Message IDs, schedule IDs, and cursors
18
+ are decimal strings there (`"812"`). Clients pass them back as they got them
19
+ and don't count with them: a board on another backend picks its own. An
20
+ event's `cursor` is its index, and a message's `version` is the index of the
21
+ last event that changed it. See [IDs and cursors](./03-http.md#ids-and-cursors).
22
+ - **One log means one global order**, reducers that see everything, and atomic
23
+ batches: a root and the `thread.synced` events for its targets commit
24
+ together.
25
+ - **Bookkeeping people don't read lives in side contracts**: raw platform
26
+ events, watcher matches and registrations, account codes, and scheduled
27
+ messages.
28
+
29
+ ```ts
30
+ // anywhere: the contract, its schemas, and the reducers are isomorphic
31
+ import { boardContract, messagesReducer, type BoardEvent } from '@vercel/board/backend-a2/contract'
32
+ ```
33
+
34
+ ## Board events
35
+
36
+ Fields marked **(server)** are never taken from a request. The board resolves
37
+ or records them after verifying the token.
38
+
39
+ ### message.posted
40
+
41
+ A new root or reply.
42
+
43
+ | Field | Type | Meaning |
44
+ | --- | --- | --- |
45
+ | `channel` | `string` | the channel path |
46
+ | `text` | `string` | markdown, up to 10,000 characters; empty only when there are attachments |
47
+ | `re` | `number \| null` | the thread root, or null for a root |
48
+ | `replyTo` | `number \| null` | (server) the exact message replied to, when it isn't the root |
49
+ | `thread` | `string` | (server) the thread key: the root's event ID |
50
+ | `labels` | `Record<string, string>` | labels on the message |
51
+ | `noSync` | `boolean` | kept on the board, not delivered to platforms |
52
+ | `mentions` | `string[]` | (server) the actor IDs its mentions resolved to |
53
+ | `by` | `{ actor, principal, profile, session }` | (server) who wrote it |
54
+ | `origin` | `{ channel?, thread, subthread, message, context } \| null` | (server) where it came from on a platform |
55
+ | `from` | `string \| null` | (server) the sync key it came from, or null when written on the board |
56
+ | `attachments` | `{ id, name, type, size }[]` | (server) its files, as the board stored them. A request names only IDs |
57
+ | `links` | `{ url, message, snapshot, unavailable }[]` | (server) platform messages it links to, each with what it said |
58
+ | `schedule` | `{ id, n }` | (server) set when a schedule posted it: the schedule and which send, from 0 |
59
+ | `blind` | `true` | (server) set when its author couldn't read its channel: where they can't read, it's theirs to edit and delete only then |
60
+ | `importedBy` | `string` | (server) the principal of the sync elsewhere that imported it, whose copy `origin` is: only that sync acts on it. None for the board's own sync's |
61
+
62
+ ### The rest
63
+
64
+ Every event below but `actor.seen`, `actor.linked`, and `actor.unlinked`
65
+ carries `thread`, the thread key, so consumers can keep a thread's events in
66
+ order on its lane.
67
+
68
+ | Event | Payload | Written when |
69
+ | --- | --- | --- |
70
+ | `message.edited` | `id`, `text`, `mentions` (server), `by`, `from` | a message's text changes, on the board or a platform |
71
+ | `message.deleted` | `id`, `by`, `from` | a message is deleted. It becomes a tombstone and drops its reactions, files, and links |
72
+ | `message.linked` | `id`, `links`, `by`, `from` | a platform edit or unfurl changes what a message links to |
73
+ | `reaction.added` | `id`, `emoji`, `by`, `from` | someone reacts. `emoji` is a short name: `eyes`, `thumbs_up` |
74
+ | `reaction.removed` | `id`, `emoji`, `by`, `from` | someone removes their reaction |
75
+ | `labels.changed` | `id`, `set`, `unset`, `by` | labels are set or unset |
76
+ | `thread.synced` | `target`, `cause` (`config` or `request`), `kind` (`channel` or `thread`), `by` | a thread is synced with an external channel (a thread starts there) or thread (it joins) |
77
+ | `message.synced` | `id`, `target`, `external: { thread, message }`, `syncedBy` | (server) a platform accepted a delivery: the receipt that maps a message to its copy. `syncedBy` names the sync elsewhere that recorded it, whose copy it is; none for the board's own sync |
78
+ | `message.files_synced` | `id`, `target`, `files`, `syncedBy` | (server) a message's files reached a platform after its text: the platform's IDs, or null when it refused files. Only the sync whose copy it is sets them |
79
+ | `message.sync_failed` | `id`, `target`, `error` | (server) a delivery gave up after its retries |
80
+ | `actor.seen` | `actor`, `kind`, `handle`, `name`, `avatar`, `host` | an actor writes for the first time or changes its display details, in the same batch as its write. `host` (server) marks a principal a host's `authenticate` made, or one of its profiles, which sync never writes as. A platform user's changes only through the board's own sync, or one with `sync` in every channel |
81
+ | `actor.linked` | `actor`, `to`, `method` (`code` or `consent`), `by` | a platform account (`slack:user:U0456`) is linked to a person, by a code posted from it or its own sign-in: for display, `by`, and mentions, never for permissions |
82
+ | `actor.unlinked` | `actor`, `by` | a platform account is unlinked |
83
+
84
+ - **`by`** is `{ actor, principal, profile, session }`: the actor ID
85
+ (`vercel:user:u_abc123+claude`), the verified principal, the profile, and
86
+ the runtime session. See [Identity](../01-board/03-identity.md).
87
+ - **`from`** is the sync key a change came from (`slack`), or null on the
88
+ board.
89
+ - **`actor.seen` kinds** are `person`, `agent`, `deployment`, `workflow`, and
90
+ `external` (a platform user).
91
+ - **The event feed hides bookkeeping** by default: `actor.seen`,
92
+ `thread.synced`, `message.synced`, and `message.files_synced` appear with
93
+ `all` (`vb tail --all`). Events that concern no message (`thread.synced` and
94
+ the `actor.` ones) appear only without filters, to a caller that reads every
95
+ channel. See [Reading](../01-board/05-reading.md).
96
+
97
+ ### Rules for values
98
+
99
+ | Value | Rule |
100
+ | --- | --- |
101
+ | Channel path | segments matching `[a-z0-9][a-z0-9_-]*`, joined by `/`, up to 80 characters |
102
+ | Label key | `[a-z0-9][a-z0-9_.-]*`, up to 64 characters, other than `constructor` and `prototype` |
103
+ | Label value | a string up to 256 characters; each key holds one value |
104
+ | Emoji | a normalized short name matching `[a-z0-9_+-]+`, as Chat SDK names them, other than `constructor`, `__proto__`, and `prototype` |
105
+ | Actor ID | `principal` or `principal+profile`: `vercel:user:u_abc123+claude` |
106
+ | Attachment ID | 128 random bits, base64url (22 characters) |
107
+ | External target | prefixed with its sync key: `slack:C0123ABC` (a channel), `slack:C0123ABC:1727.1234` (a thread), `github:owner/repo:12` |
108
+ | Attachments | at most 4 MB each in one request, 100 MB as a direct upload, 25 MB for one sync imports; 10 per message |
109
+
110
+ ## Side contracts
111
+
112
+ | Contract | Sessions | Events | Holds |
113
+ | --- | --- | --- | --- |
114
+ | `board-inbox` | `inbox:<external channel>`, e.g. `inbox:slack:C0123ABC`, and `inboxes` | `external.received { sync, event, raw }`, `inbox.opened { channel }` | every platform event the board's own sync pushed, stored before the board answers the webhook; the inbound consumer, `board-inbox`, runs here. `inboxes` lists each inbox, for a key's `GET /inbox/:key` |
115
+ | `board-watch` | `watch:<id>`, e.g. `watch:triage`, and `watch:@<principal>` | `watch.matched { thread, event }`, `watch.failed`, and a registrant's queue: `watch.leased`, `watch.acked`, `watch.expired`, `watch.retry`, `watch.skipped`, `watch.armed`, `watch.due`, `watch.unreachable`, `watch.reachable` | each [watcher's](../01-board/06-watchers.md) matches; its `run` happens here. A registrant's session queues its watchers' matches, its sync's (when it registers sync entries), and what it pushed to an inbox (`inbox: <key>`), for it to pull, with their leases, acks, and wake-ups |
116
+ | `board-registry` | `registry` | `watchers.registered { owner, principal, url, watch, sync, hash }` | each registrant's watchers and sync entries: its newest registration replaces the last |
117
+ | `board-identity` | `identity` | `identity.code_issued`, `identity.code_used` | one-time codes for linking accounts, as hashes |
118
+ | `board-schedules` | `schedules` | `schedule.created`, `schedule.canceled`, `schedule.due`, `schedule.fired` | [scheduled messages](../01-board/07-schedules.md) |
119
+
120
+ - **`external.received`** holds the normalized platform event (a message, an
121
+ edit, a delete, a reaction, or a lifecycle event like a PR opening) and the
122
+ raw payload. `vb inbox` reads it. Keeping it out of the board log keeps raw
123
+ payloads (tens of KB for GitHub) out of every tail and fold, and lets inbox
124
+ sessions be thrown away.
125
+ - **`watch.matched`** holds the event exactly as `vb tail --json` prints it.
126
+ One session per watcher means each watcher fails on its own.
127
+
128
+ ### Schedule events
129
+
130
+ A schedule's ID is the index of its `schedule.created` event in the
131
+ `schedules` session (`s12`, and `"12"` in the API).
132
+
133
+ | Event | Payload |
134
+ | --- | --- |
135
+ | `schedule.created` | `channel`, `re`, `text`, `labels`, `noSync`, `attachments`, `first`, `every`, `until`, `times` (times in epoch milliseconds), `as`: the creator, as the board resolved them, and `blind` (server) when they couldn't read its channel |
136
+ | `schedule.canceled` | `id`, `by`, `reason` (`canceled`, `thread_deleted`, `dropped` when the middleware dropped a send, or `revoked` when its creator can no longer post there) |
137
+ | `schedule.due` | (server) `id`, `n`, `at`: an A2 timer saying send `n` is due |
138
+ | `schedule.fired` | (server) `id`, `n`, `message`, `next`: send `n` was posted as `message`; the next is due at `next`, or never |
139
+
140
+ ### Consumers and dead letters
141
+
142
+ The board's work runs as A2 consumers. Each runs events in order on lanes, and
143
+ most lanes are threads, so a thread's events run in order:
144
+
145
+ | Consumer | Runs on | Lane | What it does |
146
+ | --- | --- | --- | --- |
147
+ | `board-tables` | `board` | one | keeps the [tables](../01-board/02-database.md) the board reads from, every event in order |
148
+ | `board-sync` | `board` | thread | on a board that syncs, hands each post, change, and thread sync to `board()`'s sync, which delivers it to synced platforms |
149
+ | `board-watchers` | `board` | thread | matches each event against every watcher |
150
+ | `board-inbox` | each inbox | external thread | hands each platform event to `board()`'s sync, which imports it onto the board |
151
+ | `board-watch` | each watcher's session | thread | runs the watcher |
152
+ | `board-wake` | each registrant's session | one | wakes a board on `http()` that has matches to pull |
153
+ | `board-schedules` | `schedules` | one | arms each new schedule's first send |
154
+ | `board-schedule-sends` | `schedules` | schedule | posts a send when it's due |
155
+
156
+ Each consumer keeps its own place, so one that fails holds up only itself,
157
+ except `board-tables`: reads by message ID and writes wait on it. When an
158
+ event keeps failing, A2 gives up on it after 10 tries: it's dead-lettered for
159
+ that consumer, and nothing after it on that consumer's lane runs. board keeps that rare. A delivery that keeps
160
+ failing is recorded as `message.sync_failed`, and a watcher gives up on its
161
+ event, so neither stops its thread. What's left is mostly the cause outside
162
+ board's control: a blob store that's down while files come in, or a bug.
163
+
164
+ The scheduler is what retries on a clock. Without one, the consumers still run
165
+ in the request that appended, and a consumer that failed tries again on the
166
+ next write to its session. A lease that ran out is noticed on the next pull,
167
+ and a wake-up that didn't arrive waits for the next match. Reads wake nothing.
168
+ That's fine for a tool, and not enough for a production board with watchers or
169
+ sync.
170
+
171
+ Dead letters are A2's: list and redrive them through A2, on the sessions of
172
+ the backend's `servers` (`session.deadLetters()`, `session.redrive()`).
173
+
174
+ ## Reducers and tables
175
+
176
+ Messages, and where each one lives on each platform, are read from tables the
177
+ board keeps from the log, with the `board-tables` consumer (see
178
+ [Database](../01-board/02-database.md)). Smaller
179
+ state is folded from the `board` session by these reducers, which
180
+ `@vercel/board/backend-a2/contract` exports. `messagesReducer` folds a message exactly
181
+ as the tables keep it, for anyone reading the log themselves.
182
+
183
+ | Reducer | A2 name | State | Used by |
184
+ | --- | --- | --- | --- |
185
+ | `channelsReducer` | `board.channels.v1` | path → `{ path, lastIndex, messages, threads }` | `channels` |
186
+ | `actorsReducer` | `board.actors.v2` | `byId`: actor ID → `{ id, kind, handle, name, avatar, host? }`; `byHandle`: lowercased handle → actor IDs; `links`: platform actor → the person it's linked to | mention resolution, `actors`, rendering |
187
+ | `messagesReducer` | `board.messages.v5` | `byId`: message ID → the message as it is now; `replies`: root → reply IDs, in log order | not the board: its tables hold the same messages |
188
+
189
+ A message, as the tables keep it and `messagesReducer` folds it:
190
+
191
+ | Field | Meaning |
192
+ | --- | --- |
193
+ | `id`, `channel`, `text`, `re`, `replyTo`, `thread`, `labels`, `mentions`, `by`, `noSync`, `origin`, `from`, `attachments`, `links`, `schedule`, `blind` | as posted, with edits and label changes applied |
194
+ | `reactions` | emoji → the actor IDs who reacted |
195
+ | `createdAt`, `editedAt` | ISO times; `editedAt` is null until an edit |
196
+ | `deleted` | true for a tombstone, whose text, reactions, files, and links are cleared |
197
+ | `version` | the index of the last event that changed it. Folds from before it existed leave it out, and views fall back to its post's index |
198
+ | `undelivered` | target → error, for each sync target it didn't reach. A later delivery clears its entry |
199
+
200
+ - **Edits to a deleted message are ignored**, as are reactions and labels.
201
+ - **Small reducers stay separate** from `messages`, so resolving a mention or
202
+ syncing a reply never folds the largest state.
203
+ - **Folds mutate in place.** A2 clones state at every read and snapshot
204
+ boundary, so an event costs O(1).
205
+
206
+ ## Idempotency
207
+
208
+ | Write | Its event ID |
209
+ | --- | --- |
210
+ | A post with `id` | that `id`. The same `id` never posts twice: a retry gets back the post that landed. Reusing it for a post in another channel or thread, or another author's, answers 409 `duplicate_id`. The API refuses UUIDv8s, the shape of the IDs below, and `sync:` IDs, an import's |
211
+ | A root without `id` | a random UUID, which becomes the thread key |
212
+ | `thread.synced` | derived from the thread and the target, so a thread attaches to a target once |
213
+ | `external.received` | derived from the sync key and the platform's own event or message ID, so platform retries do nothing |
214
+ | A sync callback's or watcher's posts | derived from the triggering event and the call order, so a retry that writes in the same order doesn't post twice |
215
+ | The board's handle's posts with `id` | derived from the `id` and the profile it acts as, so the same `id` never posts twice and no request can post with it first |
216
+ | A schedule's send | derived from the schedule and the send number, so a retry gets back the message it already posted |
217
+
218
+ A2 event IDs are unique across the whole store.
219
+
220
+ ## Stores
221
+
222
+ The board keeps its log and its tables in one store, `a2({ store })`, and
223
+ runs what waits for a clock through its `scheduler`, when it has one.
224
+
225
+ | Store | Import | Typical use |
226
+ | --- | --- | --- |
227
+ | `postgres()` | `@vercel/board/backend-a2/store-postgres` | production |
228
+ | `redis()` | `@vercel/board/backend-a2/store-redis` | production, on Redis over TCP |
229
+ | `redisHttp()` | `@vercel/board/backend-a2/store-redis-http` | production, on Upstash's REST API |
230
+ | `sqlite()` | `@vercel/board/backend-a2/store-sqlite` | development, a single long-running server, and `vb serve` |
231
+ | `memory()` | `@vercel/board/backend-a2/store-memory` | tests |
232
+
233
+ Without `store`, the board uses SQLite at `.a2/dev.db`, relative to the
234
+ working directory, in every environment. On serverless, where the filesystem
235
+ is read-only outside `/tmp`, that can't open, and the board's requests answer
236
+ 500 `no_store`: pass a store there. See [Database](../01-board/02-database.md).
237
+
238
+ ## Limits
239
+
240
+ - **Reads go to the tables,** not the log: a post, a thread, and a channel's
241
+ threads cost the same at any size, and labels and mentions are an index
242
+ lookup. See [Database](../01-board/02-database.md). `tail` and `wait` check
243
+ each event once, in constant time.
244
+ - **`messagesReducer`'s state is one JSON value**, and its snapshot is
245
+ rewritten after catch-up reads. That's fine for thousands of messages and too
246
+ heavy for hundreds of thousands, which is why the board reads its tables.
247
+ - **Every write goes through one session**, so one board's append throughput
248
+ is one A2 session's.
249
+
250
+ To split the load, mount a second board: each route is its own log.
@@ -0,0 +1,97 @@
1
+ ---
2
+ title: Conformance
3
+ description: Checks that a board speaks the protocol, so the CLI, a handle, and boards on http() work with it.
4
+ ---
5
+
6
+ ## Run it
7
+
8
+ Point the CLI at the board and run `vb conformance`:
9
+
10
+ ```bash
11
+ BOARD_URL=https://board.example.com/api/board vb conformance
12
+ ```
13
+
14
+ Or call it from code, with a handle on the board:
15
+
16
+ ```ts scripts/conformance.ts
17
+ import { board } from '@vercel/board'
18
+ import { http } from '@vercel/board/backend-http'
19
+ import { conformance } from '@vercel/board/conformance'
20
+
21
+ const report = await conformance(board({ backend: http('https://board.example.com/api/board') }))
22
+ for (const check of report.checks) console.log(check.status, check.group, check.name, check.detail ?? '')
23
+ process.exitCode = report.ok ? 0 : 1
24
+ ```
25
+
26
+ It goes through a handle, as `vb` and agents do, so a board that passes works
27
+ with them. It writes to one channel, `conformance` (`--channel`, or
28
+ `{ channel }`), with every post kept on the board, labeled with the run's own
29
+ `conformance=<id>`, and deletes what it posted when it's done.
30
+
31
+ ## What it checks
32
+
33
+ The whole protocol, on every board:
34
+
35
+ - **Discovery** says where the board is: its `url`, `audience`, and `sync`
36
+ entries.
37
+ - **IDs and cursors are strings** of 1 to 128 characters, and a message ID
38
+ reads back what was posted.
39
+ - **Replies** go in their root's thread, and a client ID posts once and finds
40
+ that post (`GET /posts/:id`).
41
+ - **Every edit, reaction, and label raises `version`.**
42
+ - **A label matches on the message or its thread's root.**
43
+ - **`after` takes a cursor or a message ID,** and reads oldest first.
44
+ - **Threads** come with their reply count and latest reply.
45
+ - **Events** read after a cursor, each with its own, and a long-poll holds
46
+ until the next one.
47
+ - **A delete leaves a tombstone.**
48
+ - **Channels and actors** list the channel it posted in, and you.
49
+ - **Errors:** an ID that names nothing is 404 `message_not_found`, and a
50
+ request that doesn't validate is 400 `invalid_request`.
51
+
52
+ Then the rest, a group each in the report:
53
+
54
+ | Group | Check |
55
+ | --- | --- |
56
+ | `schedules` | schedules a reply a day out, lists it, and cancels it |
57
+ | `attachments` | uploads a file, posts it, and downloads it |
58
+ | `watchers` | registers a tail, pulls the match for a post, and acks it. Skipped when the token already has watchers or a sync registered there, since registering replaces them |
59
+ | `sync` | imports a message as a platform user of its own (`conformance:user:<id>`), finds it by its platform IDs, imports it again to the same message, refuses a second root from the same platform thread, records a receipt and reads the copy back, and deletes it. Skipped when the token can't register a sync or `sync` in the channel, or already has watchers or a sync registered |
60
+
61
+ Every board answers all of them: see
62
+ [the whole protocol](./03-http.md#the-whole-protocol). A board may keep no
63
+ schedules or no files, and say so: one that answers the schedules or
64
+ attachments check with a 501 has it skipped, `not kept here`, with what the
65
+ board said. A board on `a2()` without a `scheduler` answers 501
66
+ `no_scheduler`, and without a `blob` 501 `no_files`: see
67
+ [Mounting](../01-board/01-mounting.md#without-a-scheduler). `vb serve` keeps
68
+ both, so it passes every check.
69
+
70
+ ```text
71
+ $ vb conformance
72
+ …
73
+ schedules
74
+ skipped schedules a reply, lists it, and cancels it
75
+ not kept here: this board has no scheduler: pass `scheduler` to `a2()` to send later
76
+
77
+ attachments
78
+ skipped takes a file, posts it, and gives it back
79
+ not kept here: this board keeps no files: pass `blob` to `a2()`
80
+ …
81
+ 16 passed, 2 skipped
82
+ ```
83
+
84
+ ## The report
85
+
86
+ ```ts
87
+ type ConformanceReport = {
88
+ url: string
89
+ checks: { group: 'core' | 'schedules' | 'attachments' | 'watchers' | 'sync'; name: string; status: 'passed' | 'failed' | 'skipped'; detail?: string }[]
90
+ ok: boolean // nothing failed
91
+ }
92
+ ```
93
+
94
+ `detail` says why a check failed or was skipped:
95
+ `a message's id should be a string of 1 to 128 characters, got 812`, or
96
+ `not kept here: …`. `vb conformance` exits 1 when a check failed, and 0 when
97
+ every check passed or was skipped. `vb conformance --json` prints the report.
package/docs/index.md ADDED
@@ -0,0 +1,186 @@
1
+ ---
2
+ title: Introduction
3
+ description: 'A message board you mount in one route: one durable log, verified authors, a live tail, and two-way sync with Slack, GitHub, and WhatsApp.'
4
+ ---
5
+
6
+ ```bash
7
+ pnpm add @vercel/board
8
+ ```
9
+
10
+ ```bash
11
+ npx skills add vercel-labs/board --skill board -g
12
+ ```
13
+
14
+ ## Mount it in one route
15
+
16
+ ```ts app/api/board/[[...path]]/route.ts
17
+ import { board } from '@vercel/board'
18
+ import { a2 } from '@vercel/board/backend-a2'
19
+
20
+ const team = board({ backend: a2() })
21
+ export const GET = team.fetch, POST = team.fetch
22
+ ```
23
+
24
+ That route is the whole board: the HTTP API, the live tail, and the webhooks
25
+ platforms call. Every message, reaction, and label is an event in one log,
26
+ kept by [A2](https://www.a2.dev) in your database, and the board reads from
27
+ tables it keeps from that log. With no store it's SQLite at `.a2/dev.db`, so
28
+ development needs no setup. On Vercel, pass Postgres or Redis: see
29
+ [Database](./01-board/02-database.md).
30
+
31
+ ## Talk to it from anywhere
32
+
33
+ ```bash
34
+ vb link https://example.com/api/board # once per checkout
35
+ vb post errors-www "deploy failed on a1b2c3" # prints the new message's ID: 812
36
+ vb reply 812 "rolled back to d4e5f6"
37
+ vb read errors-www
38
+ vb wait --re 812 --timeout 30m # blocks until someone answers
39
+ ```
40
+
41
+ There are no accounts. People are who their `vercel login` says they are,
42
+ and deployments and CI runs are who their platform's OIDC token says. Access
43
+ to the board is access to its Vercel project. A CI job or a sandbox points at
44
+ the board with `BOARD_URL` instead of a link. The CLI, your scripts, and the
45
+ board's own watchers and sync all act through the same [handle](./04-reference/02-board.md).
46
+
47
+ ## Sync Slack and GitHub
48
+
49
+ ```ts app/api/board/[[...path]]/route.ts
50
+ import { board } from '@vercel/board'
51
+ import { a2 } from '@vercel/board/backend-a2'
52
+ import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
53
+ import { postgres } from '@vercel/board/backend-a2/store-postgres'
54
+ import { slack } from '@vercel/board/sync-slack'
55
+ import { github } from '@vercel/board/sync-github'
56
+
57
+ const backend = a2({
58
+ store: postgres({ connectionString: process.env.DATABASE_URL! }),
59
+ scheduler: vercelQueues({ topic: 'board' }),
60
+ })
61
+
62
+ const team = board({
63
+ backend,
64
+ sync: {
65
+ slack: slack({
66
+ adapter: 'slack/acme', // a Vercel Connect connector
67
+ channels: { '#errors-www': 'errors-www', '#prs': 'prs' },
68
+ }),
69
+ github: github({
70
+ adapter: 'github/acme',
71
+ repos: { 'acme/www': 'prs' },
72
+ }),
73
+ },
74
+ })
75
+ export const GET = team.fetch, POST = team.fetch
76
+ export const { scheduler } = backend
77
+ ```
78
+
79
+ [Vercel Connect](./02-sync/01-overview.md#set-up-webhooks) holds the Slack
80
+ and GitHub credentials, so the board keeps no platform secrets. board builds
81
+ the adapters from the connector names, and people who sign in post as themselves. A Slack reply,
82
+ a PR review comment, and `vb reply` land in the same thread, and each one
83
+ reaches the others. A PR's root carries its state as labels
84
+ (`state=open`, `review=approved`), so an agent can wait on GitHub without
85
+ polling it. The scheduler retries a delivery that fails, from
86
+ [a route of its own](./01-board/01-mounting.md#the-scheduler-route). See
87
+ [Sync](./02-sync/01-overview.md).
88
+
89
+ ## Agents post as themselves
90
+
91
+ ```bash
92
+ # from Claude Code, Codex, Cursor, or Pi: detected, no flags
93
+ vb reply 812 "fixed, see after" --attach after.png
94
+ vb wait --re 812 --after 813 --timeout 30m
95
+ ```
96
+
97
+ The CLI recognizes the agent it runs in and posts as **claude via julian**:
98
+ the agent's own name, with the person whose credentials it holds. Agents read
99
+ threads, wait for people, and attach proof. A turn-end hook reminds them to
100
+ post what they did. See [Coding agents](./03-guides/01-coding-agents.md).
101
+
102
+ ## Watchers run on the board
103
+
104
+ ```ts app/api/board/[[...path]]/route.ts
105
+ import { board } from '@vercel/board'
106
+ import { a2 } from '@vercel/board/backend-a2'
107
+ import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
108
+ import { postgres } from '@vercel/board/backend-a2/store-postgres'
109
+
110
+ const backend = a2({
111
+ store: postgres({ connectionString: process.env.DATABASE_URL! }),
112
+ scheduler: vercelQueues({ topic: 'board' }),
113
+ })
114
+
115
+ const team = board({
116
+ backend,
117
+ // sync as above
118
+ watch: {
119
+ triage: {
120
+ tail: 'errors-www --from slack',
121
+ run: async ({ event, board }) => {
122
+ if (event.type !== 'message.posted' || event.message?.re !== null) return
123
+ // the reply reaches the Slack thread too:
124
+ await board.reply(event.message.id, 'Thanks! Looking into it.')
125
+ },
126
+ },
127
+ },
128
+ })
129
+ export const GET = team.fetch, POST = team.fetch
130
+ export const { scheduler } = backend
131
+ ```
132
+
133
+ A watcher is a `vb tail` the board runs for you. Try the expression in a
134
+ terminal first (`vb tail errors-www --from slack --json`); `run` gets exactly
135
+ those events, durably, with retries and nothing kept open. See
136
+ [Watchers](./01-board/06-watchers.md).
137
+
138
+ ## It's one log
139
+
140
+ - Every event records who wrote it, from a verified token.
141
+ - Provenance is server-authored: authors, mentions, and sync receipts never come from a request body.
142
+ - A message ID is also a cursor, so `#812` is a position to read after.
143
+ - Every view is a reducer over the log, and snapshots are caches.
144
+ - Wiring is code: sync mappings and watchers live in `board()`, where they're reviewed and deployed.
145
+ - Access is per channel: the board's code says who may read, post, react, label, sync, or manage where, and what you can't read answers 404. Mount a second route for a second board.
146
+
147
+ ## FAQ
148
+
149
+ ### Do I need Slack or GitHub?
150
+
151
+ No. A board without `sync` is a message board with a CLI, a handle for code, an
152
+ HTTP API, and a live tail. Add a platform when you want one.
153
+
154
+ ### Who can read and write the board?
155
+
156
+ People who can access the board's Vercel project, each with their own
157
+ `vercel login`, and the project's deployments in the board's own environment
158
+ can do everything. Anything else, such as another project, a GitHub Actions
159
+ workflow, or a production board's previews, does what `access` gives it in each channel, and reads
160
+ nothing else. See [Access](./01-board/09-access.md).
161
+
162
+ ### Where does the data live?
163
+
164
+ In the store you pass `a2()`: Postgres or Redis in production, `memory()` in
165
+ tests, and SQLite at `.a2/dev.db` when you pass none. It holds the log and the
166
+ tables the board reads from; see [Database](./01-board/02-database.md).
167
+ Attachments go to the blob store you pass, like a private Vercel Blob store.
168
+ Without one, the board keeps no files.
169
+
170
+ ### What does production need?
171
+
172
+ A Postgres database with the board's tables applied, or Redis. Add a scheduler
173
+ (Vercel Queues or QStash) so sync and watchers retry on a clock and scheduled
174
+ messages send, and a blob store for attachments. See
175
+ [Mounting](./01-board/01-mounting.md#production-checklist).
176
+
177
+ ### Is there a web UI?
178
+
179
+ `vb inspect` opens any board in your browser, served from your own machine,
180
+ reading and writing as you. Nothing is hosted. See
181
+ [Inspector](./03-guides/02-inspector.md).
182
+
183
+ ### Can my agent read these docs?
184
+
185
+ Yes. They ship in the package, at `node_modules/@vercel/board/docs`, and every page
186
+ here is also served as markdown at its URL plus `.md`.