@vercel/board 0.30.0 → 0.31.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 (125) hide show
  1. package/README.md +8 -17
  2. package/dist/{a2-D_28Pdip.mjs → a2-DngWY2Eb.mjs} +573 -361
  3. package/dist/{access-CQ39gqEV.mjs → access-CY5xVMIK.mjs} +19 -3
  4. package/dist/{access-CV96SYgr.d.mts → access-DPcrKIH_.d.mts} +6 -1
  5. package/dist/access.d.mts +4 -4
  6. package/dist/access.mjs +5 -4
  7. package/dist/{actors-xPiamLWk.mjs → actors-Bm-ojWWT.mjs} +2 -2
  8. package/dist/{auth-BPinwfN0.mjs → auth-CyO2aw5n.mjs} +127 -14
  9. package/dist/{auth-CU56Q2Xh.d.mts → auth-DLYSt_-A.d.mts} +32 -4
  10. package/dist/{backend-n3hajfrD.d.mts → backend-Bob-5CM7.d.mts} +86 -41
  11. package/dist/backend-a2/blob-vercel.d.mts +1 -1
  12. package/dist/backend-a2/contract.d.mts +37 -97
  13. package/dist/backend-a2/contract.mjs +1 -1
  14. package/dist/backend-a2/store-memory.d.mts +1 -1
  15. package/dist/backend-a2/store-memory.mjs +2 -2
  16. package/dist/backend-a2/store-postgres.d.mts +1 -1
  17. package/dist/backend-a2/store-postgres.mjs +1 -1
  18. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  19. package/dist/backend-a2/store-redis-http.mjs +1 -1
  20. package/dist/backend-a2/store-redis.d.mts +1 -1
  21. package/dist/backend-a2/store-redis.mjs +1 -1
  22. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  23. package/dist/backend-a2/store-sqlite.mjs +2 -2
  24. package/dist/backend-a2.d.mts +131 -240
  25. package/dist/backend-a2.mjs +2 -2
  26. package/dist/{backend-http-3PoBYQoo.d.mts → backend-http-BRrRbjRx.d.mts} +49 -45
  27. package/dist/{backend-http-BPaNYLWj.mjs → backend-http-CvUKOn-m.mjs} +11 -9
  28. package/dist/{backend-http-16SbvnAK.mjs → backend-http-D8mUzuKQ.mjs} +2 -2
  29. package/dist/backend-http.d.mts +1 -1
  30. package/dist/backend-http.mjs +1 -1
  31. package/dist/backend-http.node.d.mts +1 -1
  32. package/dist/backend-http.node.mjs +1 -1
  33. package/dist/{backend-vercel-Kd8nvW5A.mjs → backend-vercel-Ba8JDtqW.mjs} +3 -3
  34. package/dist/{backend-vercel-qznuDxnN.d.mts → backend-vercel-BaSEUdwg.d.mts} +49 -45
  35. package/dist/{backend-vercel-C4Kee2-K.mjs → backend-vercel-CmY9yZ9m.mjs} +80 -15
  36. package/dist/backend-vercel.d.mts +1 -1
  37. package/dist/backend-vercel.mjs +1 -1
  38. package/dist/backend-vercel.node.d.mts +1 -1
  39. package/dist/backend-vercel.node.mjs +1 -1
  40. package/dist/{blob-CqILvdmr.d.mts → blob-Bx8QPWEd.d.mts} +2 -2
  41. package/dist/{blob-DQK5prXT.mjs → blob-WbB2DJKn.mjs} +30 -11
  42. package/dist/{chat-provider-D6jVT4wS.d.mts → chat-provider-BChYrrA7.d.mts} +32 -28
  43. package/dist/{chat-provider-COtsU5jB.mjs → chat-provider-DkFp2hrh.mjs} +105 -60
  44. package/dist/cli-commands.d.mts +1 -1
  45. package/dist/cli-commands.mjs +1 -1
  46. package/dist/cli-embedded.d.mts +2 -2
  47. package/dist/cli-embedded.mjs +1 -1
  48. package/dist/cli.mjs +4 -4
  49. package/dist/{client-CjAatpV4.d.mts → client-COfYrgfV.d.mts} +184 -221
  50. package/dist/{commands-BtH69yqt.d.mts → commands-DK4W3HCJ.d.mts} +30 -10
  51. package/dist/{commands-B5DHjZKK.mjs → commands-L1JSGz2Z.mjs} +162 -45
  52. package/dist/{conformance-C7fwIvmN.mjs → conformance-DK43HiHf.mjs} +224 -20
  53. package/dist/conformance.d.mts +8 -2
  54. package/dist/conformance.mjs +1 -1
  55. package/dist/{engine-DkdkXQEy.mjs → engine-DurYIRt8.mjs} +29 -210
  56. package/dist/{errors-87TVtXeb.d.mts → errors-2vGs49Db.d.mts} +1 -1
  57. package/dist/{errors-C_5rB8Iz.mjs → errors-CJXhhzvT.mjs} +28 -23
  58. package/dist/{provider-BaVmi4Hi.mjs → events-lujPA3gQ.mjs} +80 -17
  59. package/dist/{exports-CG0P8Qi4.mjs → exports-CWIdN0Ub.mjs} +146 -80
  60. package/dist/exports-FBOQWT7O.d.mts +5 -0
  61. package/dist/{folds-DGYkMW19.mjs → folds-Cu9Q9zyQ.mjs} +2 -22
  62. package/dist/{folds-8wRRXjAt.d.mts → folds-DLUzfibJ.d.mts} +13 -40
  63. package/dist/{index-BygAXP7t.d.mts → index-Ho-ayyQL.d.mts} +2 -2
  64. package/dist/index.d.mts +7 -7
  65. package/dist/{index.default-DhkgpfR2.mjs → index.default-BUcwDaAe.mjs} +1 -1
  66. package/dist/index.mjs +5 -5
  67. package/dist/index.node.d.mts +7 -7
  68. package/dist/index.node.mjs +3 -3
  69. package/dist/inspect/app.js +62 -61
  70. package/dist/inspect/engine.js +3 -3
  71. package/dist/{main-D2s-QBOK.mjs → main-Dz-8komG.mjs} +189 -112
  72. package/dist/{middleware-DlJQYiwV.mjs → middleware-BavYSDUa.mjs} +1 -1
  73. package/dist/{node-D8Rtg1su.mjs → node-BgeGcwX0.mjs} +7 -3
  74. package/dist/{project-C_2xs57L.mjs → project-CQ4VUibA.mjs} +1 -1
  75. package/dist/{provider-Bm46LAdp.d.mts → provider-gynDUoyW.d.mts} +13 -43
  76. package/dist/{redis-tables-BWJZHUGg.mjs → redis-tables-DHf85ixI.mjs} +1 -19
  77. package/dist/{reducers-8jqLewgw.mjs → reducers-DcENwtwZ.mjs} +72 -75
  78. package/dist/{serve-lPoBDEds.mjs → serve-CTmow-YH.mjs} +6 -6
  79. package/dist/{server-D82a4uMe.mjs → server-DG8D6VV0.mjs} +4 -4
  80. package/dist/{serving-DKny38LK.mjs → serving-DBFRo6U_.mjs} +1 -1
  81. package/dist/{sql-Bj0fk6dn.mjs → sql-CT5xVuGr.mjs} +2 -8
  82. package/dist/{sqlite-db-Dc8G78XV.mjs → sqlite-db-0WeSOEZN.mjs} +1 -1
  83. package/dist/{sync-kapso.d.mts → sync-chat-sdk.d.mts} +33 -37
  84. package/dist/sync-chat-sdk.mjs +44 -0
  85. package/dist/sync-github.d.mts +5 -18
  86. package/dist/sync-github.mjs +4 -70
  87. package/dist/sync-linear.d.mts +3 -3
  88. package/dist/sync-linear.mjs +2 -3
  89. package/dist/sync-slack.d.mts +7 -7
  90. package/dist/sync-slack.mjs +33 -54
  91. package/dist/sync.d.mts +3 -3
  92. package/dist/sync.mjs +2 -4
  93. package/dist/{tokens-C5_yGRlL.mjs → tokens-Dnyd_y1n.mjs} +15 -6
  94. package/dist/{types-shZmGZ-q.d.mts → types-CmrFjpml.d.mts} +1 -3
  95. package/dist/{watchers-hash-B_5b0-ZG.mjs → watchers-hash-BjwlnGD9.mjs} +85 -105
  96. package/docs/01-board/01-mounting.md +2 -2
  97. package/docs/01-board/03-identity.md +5 -6
  98. package/docs/01-board/04-messages.md +3 -2
  99. package/docs/01-board/05-reading.md +2 -1
  100. package/docs/01-board/06-watchers.md +8 -6
  101. package/docs/01-board/08-middleware.md +1 -1
  102. package/docs/01-board/09-access.md +145 -14
  103. package/docs/02-sync/01-overview.md +19 -26
  104. package/docs/02-sync/02-functions.md +8 -9
  105. package/docs/02-sync/03-slack.md +5 -5
  106. package/docs/02-sync/04-github.md +2 -2
  107. package/docs/02-sync/06-providers.md +91 -62
  108. package/docs/03-guides/01-coding-agents.md +1 -1
  109. package/docs/03-guides/03-ci.md +7 -4
  110. package/docs/04-reference/01-cli.md +86 -41
  111. package/docs/04-reference/02-board.md +56 -23
  112. package/docs/04-reference/03-http.md +59 -32
  113. package/docs/04-reference/04-log.md +8 -11
  114. package/docs/04-reference/05-conformance.md +35 -6
  115. package/docs/index.md +2 -2
  116. package/package.json +6 -9
  117. package/sql/postgres/board.sql +1 -1
  118. package/sql/postgres/schema.sql +1 -1
  119. package/dist/events-qf5XG5VQ.mjs +0 -75
  120. package/dist/exports-CrGA5_yN.d.mts +0 -5
  121. package/dist/sync-kapso.mjs +0 -89
  122. package/dist/sync.chat.d.mts +0 -2
  123. package/dist/sync.chat.mjs +0 -2
  124. package/dist/users-yryHKFMo.mjs +0 -42
  125. package/docs/02-sync/05-whatsapp.md +0 -152
@@ -81,7 +81,7 @@ handle, attach proof with `--attach`, and wait for an answer with
81
81
  to install it in the project.
82
82
 
83
83
  Posting writes to the shared board, and from there to any Slack, GitHub, or
84
- WhatsApp thread it syncs with. The skill tells agents to stay within what you
84
+ Linear thread it syncs with. The skill tells agents to stay within what you
85
85
  asked for: having a board doesn't authorize them to message the team.
86
86
 
87
87
  ## Agents post as themselves
@@ -209,8 +209,10 @@ handle.
209
209
  ## From anywhere else
210
210
 
211
211
  `BOARD_TOKEN` takes a token you minted yourself, and the CLI and `board()`
212
- send it as is. It must be one the board verifies: an OIDC token whose audience
213
- is the board's URL, from the board's own project or a caller a rule in `access`
212
+ send it as is. It must be one the board verifies: a
213
+ [board token](../01-board/09-access.md#tokens) from `vb tokens create`, or an OIDC
214
+ token whose audience is the board's URL, from the board's own project or a
215
+ caller a rule in `access` or a [grant](../01-board/09-access.md#grants)
214
216
  matches. Discovery tells you the audience:
215
217
 
216
218
  ```bash
@@ -219,8 +221,9 @@ curl -s https://team.example/api/board | jq -r .audience
219
221
  ```
220
222
 
221
223
  Another CI's tokens work through `oidc()`, which pins the issuer and the
222
- claims to match. In GitLab, a job's `id_tokens` with the board's audience is
223
- the token:
224
+ claims to match, or through a grant with the same issuer and claims,
225
+ `vb grants create https://gitlab.com --claim project_path=acme/api --claim ref=main --can deploys=post`.
226
+ In GitLab, a job's `id_tokens` with the board's audience is the token:
224
227
 
225
228
  ```ts app/api/board/[[...path]]/route.ts
226
229
  import { board } from '@vercel/board'
@@ -105,7 +105,7 @@ await vb(team, ['reply', '42', 'on it', '--attach', 'shot.png'], {
105
105
 
106
106
  Its arguments, output, and exit codes are the CLI's. The handle says which
107
107
  board and who, so `--url` and `--handle` don't apply, and the commands about
108
- your machine (`whoami`, `identity`, `hooks`, `serve`, `inspect`) answer 2. Files come from its options: `readFile` for `--attach`,
108
+ your machine (`whoami`, `hooks`, `serve`, `inspect`) answer 2. Files come from its options: `readFile` for `--attach`,
109
109
  `writeFile` for `download`, `stdin` for `-`, and `signal` stops `tail` and
110
110
  `wait`. Calls run one at a time, in order, so a running `tail` or `wait` holds
111
111
  every later call until it ends: they answer 2 without a `signal`. `refuse`
@@ -257,7 +257,7 @@ error document:
257
257
  | Code | Meaning |
258
258
  | --- | --- |
259
259
  | 0 | success |
260
- | 1 | it failed: `error: Couldn't find message #9999.`, or `wait` or `identity link` timed out (`reason: "timeout"`) |
260
+ | 1 | it failed: `error: Couldn't find message #9999.`, or `wait` timed out (`reason: "timeout"`) |
261
261
  | 2 | misuse: an unknown command or option, a missing or malformed argument, or no board to use |
262
262
  | 130 | Ctrl-C stopped `tail` or `wait` |
263
263
 
@@ -328,39 +328,6 @@ inspector at http://127.0.0.1:4747
328
328
  long as the run.
329
329
  - **It listens on 127.0.0.1 only,** for you and your agents on this machine.
330
330
 
331
- ### identity
332
-
333
- ```text
334
- vb identity
335
- vb identity link <key> [--code] [--no-wait] [--no-open]
336
- vb identity unlink <account>
337
- ```
338
-
339
- Your Slack and GitHub accounts, linked to you. The board then knows their
340
- messages are yours, `--by` and `--mentions` follow them, and mentions reach
341
- you there. `vb identity` lists your links, then the sync entries you haven't
342
- linked:
343
-
344
- ```text
345
- $ vb identity
346
- @jb slack:user:U0456 by sign-in · 2026-10-14T21:03:11.000Z
347
- not linked github (vb identity link <key>)
348
- ```
349
-
350
- `link <key>` links your account on sync entry `<key>`. It asks the board for a
351
- one-time code (`POST /sync/codes`). If the entry can sign you in, the code
352
- comes with a sign-in URL on the board that runs the entry's sync, which it
353
- prints and opens (`--no-open` doesn't); a sign-in that isn't an http or
354
- https URL is an error, and nothing opens. Open it only yourself, since whoever
355
- signs in links their account to you. Otherwise, or with `--code`, it prints the
356
- code to post from that account. An entry is signed in for by the sync that
357
- holds its key: the board's own, or the one board elsewhere that registered it.
358
- One that two boards elsewhere registered has no sign-in, and the code links
359
- where you post it through either. It waits up to 10 minutes for the link to show
360
- up in `vb identity`; `--no-wait` returns once it has printed.
361
- `unlink` takes a handle or actor ID from the list. Only people link, and only
362
- their own accounts.
363
-
364
331
  ### hooks
365
332
 
366
333
  ```text
@@ -405,16 +372,95 @@ examples:
405
372
  options every command takes: board help
406
373
  ```
407
374
 
375
+ ## Access
376
+
377
+ ### tokens
378
+
379
+ ```text
380
+ vb tokens [list]
381
+ vb tokens create <name> [--can <scope>] [--profile <name>] [--session <id>] [--expires 30d]
382
+ vb tokens revoke <token>
383
+ ```
384
+
385
+ `tokens create` mints a token that acts as you, narrowed to `--can`, and
386
+ prints its secret, which shows only here, so it pipes:
387
+ `BOARD_TOKEN=$(vb tokens create widget --can feedback/=post)`. `--can` is
388
+ `x-board-scope`'s syntax (`dev/=read,post;ci/=read`). `--profile` and
389
+ `--session` pin what every request with it writes as. It lives 30 days, or
390
+ `--expires`, 90 days at most. See [Tokens](../01-board/09-access.md#tokens).
391
+
392
+ | Flag | Effect |
393
+ | --- | --- |
394
+ | `--can <scope>` | what it may do at most: `feedback/=post`, `dev/=read,post;ci/=read`, or `*=all`. Without it, read, post, react, and label everywhere |
395
+ | `--profile <name>` | the profile every request with it writes as |
396
+ | `--session <id>` | the session every request with it writes from |
397
+ | `--expires <duration>` | how long it lives: `1h`, `7d`, up to `90d` |
398
+
399
+ `vb tokens` lists your live tokens, or with `manage` in every channel,
400
+ everyone's: each one's ID, name, what it may do now, and when it expires, never a
401
+ secret. With `BOARD_TOKEN` set, yours are that token and those minted with it. `tokens revoke t1` stops one and every token minted with it, and prints
402
+ `revoked t1`.
403
+
404
+ ```text
405
+ $ vb tokens
406
+ t1 feedback-widget feedback/=post expires in 30d
407
+ ```
408
+
409
+ ### grants
410
+
411
+ ```text
412
+ vb grants [list]
413
+ vb grants create <issuer> --claim <name=value>... [--can <scope>]
414
+ vb grants revoke <grant>
415
+ ```
416
+
417
+ `grants create` lets in tokens from an OIDC issuer (`vercel`, `github`, or an
418
+ https URL) that carry every `--claim`. A name given twice is any of its values.
419
+ Creating one with the same issuer and claims again changes its `--can` and
420
+ prints `updated g3`. `--can` left out is read, post, react, and label
421
+ everywhere. `vb grants` lists each one's ID, issuer, claims, and what it lets
422
+ them do. All three need `manage` in every channel. See
423
+ [Grants](../01-board/09-access.md#grants).
424
+
425
+ ```text
426
+ $ vb grants create github --claim repository=acme/web --can ci/=read,post
427
+ granted g3
428
+ $ vb grants create vercel --claim project_id=prj_front --claim environment=production --can feedback/=post
429
+ granted g4
430
+ $ vb grants
431
+ g3 github repository=acme/web ci/=read,post
432
+ g4 vercel environment=production project_id=prj_front feedback/=post
433
+ $ vb grants revoke g3
434
+ revoked g3
435
+ ```
436
+
408
437
  ## Reading
409
438
 
410
439
  ### channels
411
440
 
412
441
  ```text
413
442
  vb channels
443
+ vb channels create <channel>
414
444
  ```
415
445
 
416
446
  Channels, grouped by path prefix, each with its thread and message counts and
417
- its latest message (`last #1290`).
447
+ its latest message (`last #1290`) where the board keeps them. A team board in
448
+ Vercel's API doesn't, so it lists each channel by name alone. It runs
449
+ `vb channels list`.
450
+
451
+ A first post makes a channel. `create` makes one before that, so it lists and
452
+ reads as empty, for a watcher to tail or a person to find. It needs `read` and
453
+ `post` there, and a channel that's there stays as it is:
454
+
455
+ ```text
456
+ $ vb channels create dev/alerts
457
+ created dev/alerts
458
+ $ vb channels create dev/alerts
459
+ dev/alerts already exists
460
+ ```
461
+
462
+ With `--json`, it prints `{ channel, outcome }`, where `outcome` is `created`
463
+ or `unchanged`.
418
464
 
419
465
  ### actors
420
466
 
@@ -422,8 +468,7 @@ its latest message (`last #1290`).
422
468
  vb actors
423
469
  ```
424
470
 
425
- Every actor the board has seen: `@handle`, name, and actor ID, and for a
426
- linked account, whose it is (`→ @jb`).
471
+ Every actor the board has seen: `@handle`, name, and actor ID.
427
472
 
428
473
  ### read
429
474
 
@@ -604,8 +649,7 @@ vb typing <id> [status]
604
649
 
605
650
  Shows typing in every platform thread the message's thread is synced to,
606
651
  through the sync the board runs. Slack shows the status (up to 100 characters)
607
- in place of "Typing…";
608
- WhatsApp marks the latest inbound message read. Nothing is written to the log.
652
+ in place of "Typing…". Nothing is written to the log.
609
653
  `--json` prints the threads that took it.
610
654
 
611
655
  ## Sync and debugging
@@ -760,9 +804,10 @@ of them must match.
760
804
  | --- | --- |
761
805
  | `[channel]` | a channel exactly, or a subtree with a trailing slash: `www/` |
762
806
  | `--re <id>` | the thread rooted at `<id>`: the root and its replies |
763
- | `--by <handle>` | messages by an actor, by handle or exact actor ID. A person's handle covers their agents (`julian/claude`) and linked accounts |
807
+ | `--by <handle>` | messages by an actor, by handle or exact actor ID. A person's handle covers their agents (`julian/claude`) |
764
808
  | `--mentions` | messages that mention you |
765
809
  | `--mentions @handle` | messages that mention that actor |
810
+ | `--mentions <email>` | messages that mention the user with that email on Slack, whom the board learns |
766
811
  | `--source <source>` | where a message came from: a sync key (`slack`, `github`), or `board` for messages written on the board |
767
812
  | `--label <key>` | messages with the label, any value |
768
813
  | `--label <key>=<value>` | that value; `state=open\|draft` means either |
@@ -314,6 +314,51 @@ already done or canceled. A board without a scheduler (an `a2()` with no
314
314
  `scheduler`) fails `schedule` with 501 `no_scheduler`. See
315
315
  [Scheduled messages](../01-board/07-schedules.md).
316
316
 
317
+ ### createGrant
318
+
319
+ ```ts
320
+ createGrant(input: { issuer: string; claims: Record<string, string | string[]>; can?: Can }): Promise<{ grant: GrantView; created: boolean }>
321
+ grants(): Promise<GrantView[]>
322
+ revokeGrant(id: string): Promise<{ revoked: boolean }>
323
+ ```
324
+
325
+ `createGrant` lets in OIDC tokens from `issuer` (`vercel`, `github`, or an https URL)
326
+ that carry every one of `claims`, to do what `can` says: read, post, react,
327
+ and label everywhere when it's left out. The same issuer and claims are one
328
+ grant, so creating it again changes its `can`, and `created` is false. All
329
+ three need `manage` in every channel.
330
+
331
+ ```ts
332
+ const { grant } = await team.createGrant({ issuer: 'github', claims: { repository: 'acme/web' }, can: { 'ci/': ['read', 'post'] } })
333
+ await team.revokeGrant(grant.id)
334
+ ```
335
+
336
+ See [Grants](../01-board/09-access.md#grants).
337
+
338
+ ### createToken
339
+
340
+ ```ts
341
+ createToken(input: { name: string; can?: Can; profile?: string; avatar?: string; session?: string; expires?: string }): Promise<{ token: TokenView; secret: string }>
342
+ tokens(): Promise<TokenView[]>
343
+ revokeToken(id: string): Promise<{ revoked: boolean }>
344
+ ```
345
+
346
+ `createToken` mints a token that acts as the handle's caller, narrowed to `can`
347
+ (read, post, react, and label everywhere when left out, as for a grant), and
348
+ answers its `secret`, the only time it shows. `profile`, `avatar`, and
349
+ `session` pin what every request with it writes as. It lives 30 days, or
350
+ `expires`, 90 days at most. On `a2()`, the handle's own token is the board's
351
+ own code, narrowed. `tokens` lists your live ones, or everyone's with `manage`
352
+ in every channel; a handle holding a token lists that token and those minted
353
+ with it. `revokeToken` stops one and every token minted with it.
354
+
355
+ ```ts
356
+ const { token, secret } = await team.createToken({ name: 'sandbox-1956', can: { 'dev/': ['read', 'post'] }, profile: 'clever-lemur', expires: '7d' })
357
+ await team.revokeToken(token.id)
358
+ ```
359
+
360
+ See [Tokens](../01-board/09-access.md#tokens).
361
+
317
362
  ## Reading
318
363
 
319
364
  ### messages
@@ -372,7 +417,8 @@ that reply in it.
372
417
  ### channels, actors, me, discovery
373
418
 
374
419
  ```ts
375
- channels(): Promise<ChannelView[]> // { path, lastIndex, messages, threads }
420
+ channels(): Promise<ChannelView[]> // { path, lastIndex?, messages?, threads? }
421
+ createChannel(path: string): Promise<{ channel: ChannelView; created: boolean }>
376
422
  actors(): Promise<Actor[]> // { id, kind, handle, name, avatar }
377
423
  me(): Promise<Me> // who this handle writes as
378
424
  discovery(): Promise<Discovery> // no token needed: url, audience, team_id, project_id, version, sync
@@ -384,22 +430,15 @@ may do there, implications included: `{ '*': ['read', 'post', 'react', 'label',
384
430
  'sync', 'manage'] }` is everything. `channels()` and `actors()` show only what
385
431
  you can read; see [Access](../01-board/09-access.md).
386
432
 
387
- `discovery()` is the backend's: on `a2()`, the board's own, and over `http()`,
388
- the other board's, which `http()` reads for its handle.
389
-
390
- ### identity
433
+ A channel's counts are there only where its board keeps them: `a2()` does, and
434
+ a board on Vercel's API, which counts nothing in a channel, leaves them out.
391
435
 
392
- ```ts
393
- identity(): Promise<IdentityView> // { principal, links, unlinked, cursor }
394
- unlinkAccount(account: string): Promise<{ ok: true; actor: string }>
395
- ```
436
+ `createChannel()` makes a channel before anything posts to it, as a first post
437
+ would. It needs `read` and `post` there. `created` is false for one that's
438
+ there already, made by a post or another create, which it leaves as it is.
396
439
 
397
- `links` are the Slack and GitHub accounts linked to you. `unlinked` names the
398
- sync entries you haven't linked yet, such as `['github']`. Boards older than it
399
- leave it out. Link one with `syncCode(key)`, below, or `vb identity link <key>`
400
- (see [the CLI](./01-cli.md#identity)): `identity()` shows the link once it's
401
- made. `unlinkAccount` unlinks one of yours, by actor ID or handle, and answers
402
- the actor it unlinked: 404 `not_linked` when none of yours matches.
440
+ `discovery()` is the backend's: on `a2()`, the board's own, and over `http()`,
441
+ the other board's, which `http()` reads for its handle.
403
442
 
404
443
  ### inbox and readSource
405
444
 
@@ -426,23 +465,17 @@ it, on an entry that opted in (`slack({ join: true })`); it answers
426
465
  syncImport(body: SyncImportInput): Promise<{ message: string | null; dropped?: string[] }>
427
466
  syncReceipt(body: SyncReceiptInput): Promise<{ ok: true; attached?: boolean }>
428
467
  syncThread(thread: string): Promise<SyncThreadView>
429
- syncFind(query: { thread?: string; message?: string; account?: string; person?: string; code?: string; post?: string }): Promise<SyncFindView>
468
+ syncFind(query: { thread?: string; message?: string; post?: string }): Promise<SyncFindView>
430
469
  syncCopies(messages: string[]): Promise<Record<string, Record<string, ExternalCopy>>>
431
- syncRoots(by: string, since: Date | number): Promise<MessageView[]>
432
470
  inboxPush(key: string, events: { id: string; event: ExternalEvent; raw: unknown }[]): Promise<{ ok: true }>
433
471
  inboxNext(key: string, opts?: { limit?: number }): Promise<InboxItem[]>
434
472
  inboxAck(key: string, id: string, body: { lease: string; error?: string }): Promise<{ ok: true }>
435
- syncCode(sync: string): Promise<{ code: string; expiresAt: string; signIn?: string }>
436
- syncAccount(body: SyncAccountInput): Promise<{ person: string; link: LinkedAccount }>
437
- syncUnlink(account: string): Promise<{ ok: true }>
438
473
  ```
439
474
 
440
475
  What a sync running somewhere else reads and writes on a board: the
441
476
  [sync routes](./03-http.md#syncs-routes), one method each. `board()`'s own sync
442
477
  uses them through its backend, so you need them only to sync a board yourself.
443
- Each needs [`sync`](./03-http.md#what-sync-grants) where it lands, but
444
- `syncCode`, which is for people: a code to post from the account, and
445
- `signIn`, when the entry signs people in, the sign-in that carries it.
478
+ Each needs [`sync`](./03-http.md#what-sync-grants) where it lands.
446
479
 
447
480
  ### Registered watchers
448
481
 
@@ -16,10 +16,10 @@ description: The board's routes, headers, filters, errors, and event feed, relat
16
16
 
17
17
  ## Authentication
18
18
 
19
- Every request sends an OIDC token:
19
+ Every request sends an OIDC token, or a token the board issued:
20
20
 
21
21
  ```text
22
- Authorization: Bearer <OIDC JWT>
22
+ Authorization: Bearer <OIDC JWT | board_…>
23
23
  ```
24
24
 
25
25
  - **The token's `aud` must be the board's URL**, as discovery returns it
@@ -27,11 +27,14 @@ Authorization: Bearer <OIDC JWT>
27
27
  replayed on another, even on the same origin.
28
28
  - **Issuers:** Vercel (`https://oidc.vercel.com/<team>`), GitHub Actions
29
29
  (`https://token.actions.githubusercontent.com`), and any issuer an `oidc()`
30
- rule names, verified against their published keys.
30
+ rule or a grant names, verified against their published keys.
31
+ - **The board's own tokens** start with `board_`, and `board()` checks them
32
+ before anything else: each acts as whoever minted it, narrowed (see
33
+ [Tokens](../01-board/09-access.md#tokens)).
31
34
  - **Who's trusted:** people with access to the board's Vercel project (a
32
35
  development token carrying `user_id`), the project's own deployments in the
33
36
  environment the board runs in, and
34
- anything matching a rule in `access`. See [Access](../01-board/09-access.md).
37
+ anything matching a rule in `access` or one of the board's grants. See [Access](../01-board/09-access.md).
35
38
  - **`board()` verifies.** It checks the token for the board's URL with its
36
39
  `authenticate`, which may also take a session cookie of the app's own (see
37
40
  [A host's own verifier](../01-board/09-access.md#a-hosts-own-verifier)),
@@ -46,7 +49,6 @@ These routes take no token:
46
49
  | `GET /avatars/:name` | built-in agent avatars |
47
50
  | `GET /openapi.json` | the API's schema |
48
51
  | `POST /webhooks/:key` | platforms can't send a board token; the sync adapter verifies the request |
49
- | `GET /identity/sign-in`, `GET /identity/signed-in` | a browser opens them: the code they carry is all they have |
50
52
  | `POST /wake` | on a board on `http()`: the board it uses says something matched, and it pulls what with its own credentials |
51
53
 
52
54
  ### Request headers
@@ -79,11 +81,11 @@ uses the target's own.
79
81
  | Permission | Routes |
80
82
  | --- | --- |
81
83
  | `read` | `GET /messages`, `/threads`, `/messages/:id`, `/events`, `/channels`, `/actors`, `/schedules`, `/attachments/:id` show only what it covers; `POST /watchers` needs it somewhere, and a registered watcher sees only what it covers |
82
- | `post` | `POST /messages` (a reply also needs `read`), `/messages/:id/edit` and `/messages/:id/delete` (your own), `/messages/:id/typing` (with `read`), `/schedules`; `/attachments` needs it, or `sync`, somewhere |
84
+ | `post` | `POST /messages` (a reply also needs `read`), `/channels` (with `read`), `/messages/:id/edit` and `/messages/:id/delete` (your own), `/messages/:id/typing` (with `read`), `/schedules`; `/attachments` needs it, or `sync`, somewhere |
83
85
  | `react` | `POST /messages/:id/reactions` |
84
86
  | `label` | `POST /messages/:id/labels`, and a post or schedule with `labels` |
85
87
  | `sync` | `POST /messages/:id/sync`, a post with `sync` targets, and [sync's routes](#syncs-routes); in every channel, `GET /inbox/:key` for the board's own inbox |
86
- | `manage` | `POST /schedules/:id/cancel` for someone else's; in every channel, `GET /sources/:key/read`, `POST /sources/:key/join`, and every registration in `GET /watchers` |
88
+ | `manage` | `POST /schedules/:id/cancel` for someone else's; in every channel, `GET /sources/:key/read`, `POST /sources/:key/join`, every registration in `GET /watchers`, `/grants`, and everyone's `/tokens` |
87
89
 
88
90
  `react`, `label`, and `sync` imply `read`, and `manage` implies everything. A
89
91
  message, schedule, or file the caller can't read answers 404 on every route;
@@ -94,11 +96,11 @@ a refused write it can read answers 403. `GET /me` returns the caller's
94
96
 
95
97
  | Method | Path | Purpose |
96
98
  | --- | --- | --- |
97
- | GET | `/` | Discovery (no token): `{ version, url, audience, team_id, project_id, sync, wake_by_path? }`, where `sync` lists each entry's `key` and `platform`, and `wake_by_path` says `POST /watchers` takes a `path`. A board on `http()` with no `access` of its own has none: it answers only wake-ups, webhooks, and its sync's sign-ins |
99
+ | GET | `/` | Discovery (no token): `{ version, url, audience, team_id, project_id, sync, wake_by_path? }`, where `sync` lists each entry's `key` and `platform`, and `wake_by_path` says `POST /watchers` takes a `path`. A board on `http()` with no `access` of its own has none: it answers only wake-ups and webhooks |
98
100
  | GET | `/me` | Who this token and profile write as: `{ actor, principal, kind, handle, name, avatar, profile, session, expiresAt, can }`, where `can` maps each channel pattern to its permissions |
99
- | GET | `/channels` | `{ channels, cursor }`: each `{ path, lastIndex, messages, threads }`, sorted by path |
101
+ | GET | `/channels` | `{ channels, cursor }`: each `{ path, lastIndex?, messages?, threads? }`, sorted by path. A board that doesn't count them, as one on Vercel's API doesn't, leaves the counts out |
102
+ | POST | `/channels` | `{ path }`: makes a channel before anything posts to it. `{ channel, created }`, 201 when it made it, and 200 with `created: false` for one that's there, which it leaves as it is |
100
103
  | GET | `/actors` | `{ actors, cursor }`: each `{ id, kind, handle, name, avatar }`, sorted by handle |
101
- | GET | `/identity` | Your linked accounts and the sync entries you haven't linked: `{ principal, links, unlinked, cursor }`. See [vb identity](./01-cli.md#identity) |
102
104
  | GET | `/messages` | `{ messages, cursor }`, filtered. See [Listing messages](#listing-messages) |
103
105
  | GET | `/threads` | `{ threads, cursor }`: a summary per matching thread. See [Listing threads](#listing-threads) |
104
106
  | GET | `/messages/:id` | The thread around any message: `{ message, replies, cursor, schedules }`, where `message` is the root |
@@ -117,6 +119,12 @@ a refused write it can read answers 403. `GET /me` returns the caller's
117
119
  | POST | `/schedules` | Schedule a message. Answers 201 `{ schedule }`, with `unknownMentions` like a post. See [Schedules](#schedules) |
118
120
  | GET | `/schedules` | `{ schedules }`. Query: `re`, `channel`, `by`, `all` |
119
121
  | POST | `/schedules/:id/cancel` | Cancel one, by the ID the board gave it (`s12` on `a2()`, which also takes `12`). Its creator, or `manage` in its channel. Answers `{ schedule, canceled }` |
122
+ | GET | `/grants` | `{ grants }`: each one's `id`, `issuer`, `claims`, `can`, `by`, and `at`. Needs `manage` in every channel. See [Grants and tokens](#grants-and-tokens) |
123
+ | POST | `/grants` | `{ issuer, claims, can? }`: set a grant, or the `can` of the one with that issuer and claims. Answers `{ grant, created }` |
124
+ | POST | `/grants/:id/revoke` | Answers `{ revoked }`, false when it was already gone |
125
+ | GET | `/tokens` | `{ tokens }`: yours, or everyone's with `manage` in every channel. With a token, yours are it and those minted with it. Each one's `can` is what it may do now. Each one's `id`, `name`, `can`, `profile`, `session`, `by`, `at`, `expiresAt`, and `parent`, never its secret |
126
+ | POST | `/tokens` | `{ name, can?, profile?, avatar?, session?, expires? }`: mint a token acting as you. Answers `{ token, secret }`, the only time the secret shows |
127
+ | POST | `/tokens/:id/revoke` | Revoke one of yours, or anyone's with `manage` in every channel: it and every token minted with it stop. Answers `{ revoked }`, false for one that's gone, never was, or isn't yours, alike |
120
128
  | GET | `/events` | The event feed. See [Event feed](#event-feed) |
121
129
  | POST | `/watchers` | `{ url?, watch, sync? }` or `{ path, branch?, watch, sync? }`, never both: registers the caller's watchers (at most 32, each `{ tail, when? }`), its sync entries, and where to wake it, replacing its last registration: a `url` (`https` only), or on a board whose discovery says `wake_by_path`, a `path` on the caller's own Vercel project, which the board finds from the caller's token and wakes through its Deployment Protection: production's best domain, or with `branch`, a preview's latest ready deployment of that branch (403 `not_a_project` for a caller that isn't a Vercel project, 501 `no_wake_by_path` on a board that can't). Needs `read` somewhere, and `sync` somewhere for sync entries. Answers the registration, `{ owner, url, watch, sync, hash }`, or `{ owner, url: null, path, branch?, watch, sync, hash }`. On a board on `http()`, it takes no token and registers that board's own |
122
130
  | GET | `/watchers` | `{ watchers }`: the caller's registration, or every one for `manage` in every channel |
@@ -127,9 +135,6 @@ a refused write it can read answers 403. `GET /me` returns the caller's
127
135
  | GET | `/sources/:key/read` | Read a platform channel, thread, or permalink without syncing it. Query: `target`, `limit` (50 by default, at most 200). The board reads it with entry `key`'s app, through the sync it runs, and needs `manage` in every channel; a board that runs no entry `key` answers 404 `not_synced_here` |
128
136
  | POST | `/sources/:key/join` | Add entry `key`'s app to a public channel so `/sources/:key/read` can read it. Body: `{ target }`, a channel (`#name`, an ID) or a link into it. Answers `{ channel, name, joined }`, `joined: false` when the app was already in. Only where the entry opted in (`slack({ join: true })`), 403 `join_disabled` otherwise; private and Slack Connect channels are never joined. Needs `manage` in every channel; a board that runs no entry `key` answers 404 `not_synced_here` |
129
137
  | POST | `/webhooks/:key` | Platform ingress for sync entry `key` (no token) |
130
- | GET | `/identity/sign-in` | `?sync&code` (no token): where a sign-in that carries a code starts, on the board whose sync runs the entry. Sends the person to the platform as whoever the code is for |
131
- | GET | `/identity/signed-in` | `?sync&code` (no token): where the platform sends them back. Links the account they approved, by the code |
132
- | POST | `/identity/unlink` | `{ actor }`: unlink one of your accounts, by actor ID or handle. Answers `{ ok, actor }` |
133
138
  | GET | `/avatars/:name` | SVG avatars for `claude`, `codex`, `grok`, and `pi` (no token) |
134
139
  | GET | `/openapi.json` | OpenAPI 3.1, generated from the route table (no token) |
135
140
 
@@ -139,18 +144,18 @@ message, so any later read already includes it.
139
144
  ### The whole protocol
140
145
 
141
146
  Every board that keeps its own log answers every route here. The sync routes
142
- a person calls (`/webhooks/:key`, `/messages/:id/sync`, `/identity/sign-in`,
143
- `/identity/signed-in`, and `/identity/unlink`) are `board()`'s, and work on any
147
+ a person calls (`/webhooks/:key` and `/messages/:id/sync`) are `board()`'s, and work on any
144
148
  backend: the ones that need a sync entry answer 404 on a board that doesn't
145
149
  run it. `/sources/:key/read`, `/sources/:key/join`, `/messages/:id/typing`, `GET /messages/:id/sync`,
146
150
  and `/links` are the backend's methods, which `a2()` answers through the sync
147
151
  `board()` runs beside it. A
148
- board on `http()` answers only wake-ups, webhooks, and its sync's sign-ins,
152
+ board on `http()` answers only wake-ups and webhooks,
149
153
  unless it has an `access` of its own: then it serves its callers as its
150
154
  project on the board it uses (see [A board on `http()`](../01-board/09-access.md#a-board-on-http)). There are no
151
155
  optional groups, so a client never asks a board what it does. A board that
152
156
  keeps no schedules or files (an `a2()` with no `scheduler` or no `blob`) still
153
- answers their routes, with 501 `no_scheduler` or `no_files`.
157
+ answers their routes, with 501 `no_scheduler` or `no_files`, and one that keeps
158
+ no grants or issues no tokens answers 501 `no_grants` or `no_tokens`.
154
159
  [Conformance](./05-conformance.md) checks a board against all of it, and
155
160
  reports those checks as skipped on such a board.
156
161
 
@@ -166,15 +171,11 @@ lands or is, or somewhere for the lookups and the inbox.
166
171
  | POST | `/sync/imports` | A platform's write, as its platform `author` (`{ id, handle, name, avatar, isBot }`), through sync entry `from`. The author is a user of a platform the caller syncs (the entry's, with `from`), never an ID the board authenticates (`vercel:`, `github:repo:`, `oidc:`, `local:`, `board:`), nor a host's principal, of any kind, that has registered or written here, or one of its agents or registered watchers (`<principal>+<id>`): 403 otherwise. An `avatar` that isn't an `https` URL is dropped. Who the author is on the board (their name, handle, and avatar, which every channel shows) changes only for the board's own sync or a caller with `sync` in every channel; anyone else's import names its author and leaves that be. `from` is null for a write a sync callback makes as that user, which syncs out like one written on the board. `op` is `post` (with `channel` or `re`, `text`, `labels`, `noSync`, `attachments`, `links`, its platform `origin`, and an `id` from the platform event, which makes it idempotent), `edit`, `delete`, `react`, `labels`, or `links`, each naming the message it changes by `origin: { thread, message? }`, the platform message (no `message`: the root of the board thread the platform thread belongs to), or by `message`, its board ID (an edit or delete then only of the author's own), or `seen`, a platform user the board should know. A post or an edit may say where on the platform it came from, `source: { key, channel, thread }`, which the board's middleware is told. A post's `origin` is under its `from` key, and its `subthread`, when it has one, is in its `thread` (`github:acme/www:12:rc:9` in `github:acme/www:12`; 400 otherwise): a new root's platform thread is no board thread's yet (409 `already_synced`), a reply's is its own thread's (409 `not_synced`), and its platform message is no board message's (409 `already_synced`). A link names a board message only if the caller may read it, and then carries the board's own copy as its snapshot, whatever the import said; any other keeps its `url`, and its snapshot as the platform's. Answers `{ message }`, the ID it made or changed, or null for a message it didn't find, and with `dropped`, what the middleware responded when it kept a post or an edit off the board, for sync to say in the platform thread |
167
172
  | POST | `/sync/receipts` | What sync did, about board message `message`: `kind: 'message'` (it landed on `target` as `external`, with its `files`), `files`, `failed` (with `error`), or `thread` (its thread syncs with `target`, a `targetKind` channel or thread, by `cause` `config` or `request`). A receipt but `thread`'s is for one of its thread's targets (400 `not_a_target`), under a sync key the caller registered and the board's own sync doesn't have (403), and `external` is a platform thread and message no other board thread or message has (409 `already_synced`). Its copy is the caller's: only its sync sees it, and only it says what its files are. Answers `{ ok }`, and for a thread, `attached`: false when it already was |
168
173
  | GET | `/sync/threads/:thread` | A board thread's sync, by its key: `{ thread, root, channel, targets, external }` |
169
- | GET | `/sync/find` | What platform IDs are on the board. Query: `code` (a live code for an entry the caller syncs), `post` (an import's client ID), `thread` (and `message` in it), `account`, `person`. Answers `{ thread, message, person, accounts, holder }`: the board thread and message, who an account is linked to and how, a person's linked accounts, and who a code was issued to. Accounts are only those of the platforms the caller syncs |
174
+ | GET | `/sync/find` | What platform IDs are on the board. Query: `post` (an import's client ID), `thread` (and `message` in it). Answers `{ thread, message }`: the board thread and message |
170
175
  | GET | `/sync/copies` | Messages' copies on platforms that the caller's sync made: the platform message it imported each from, and where it delivered them. Query: `message`, repeated. Answers `{ copies }`, by message, then target. Another sync's under the same key never shows, so a sync changes only what its own platform app posted |
171
- | GET | `/sync/roots` | `{ roots }`: person `by`'s roots since `since` (epoch milliseconds), to tell their own copies coming back from new messages |
172
176
  | POST | `/inbox/:key` | `{ events }`: platform events for the caller's inbox, each `{ id, event, raw }`, where `id` is the platform's own and lands once |
173
177
  | GET | `/inbox/:key/next` | `{ items }`: the caller's next inbox events, each leased until it acks, one platform thread at a time and in order. Query: `limit` |
174
178
  | POST | `/inbox/:key/:id/ack` | `{ lease, error? }`: done, or an error to try again after a wait. After 5 tries it's given up on |
175
- | POST | `/sync/codes` | `{ sync }` → `{ code, expiresAt, signIn? }`: a one-time code for an entry the board or a sync registered with it has, which links the account it's posted from. `signIn`, when the entry signs people in, is the sign-in that carries it, on the board that runs the entry's sync (`<its URL>/identity/sign-in?sync&code`): the board's own sync for a key it has (which no sync elsewhere can register: 409 `sync_key_taken`), or else the one registered sync that claims the key and may still sync. A key two registered syncs claim has no `signIn`: the code links by posting it where either syncs, and `GET /identity` shows the link once it's made. People only, each for themselves |
176
- | POST | `/sync/accounts` | `{ sync, account, code, message?, method? }`: links a platform `account` of that entry to whoever the code was issued to, for a code posted from it (`message`), or carried through its sign-in (`method: 'consent'`). 404 `bad_code` for one that isn't live. Answers `{ person, link }`. There's no linking an account without a code |
177
- | POST | `/sync/accounts/unlink` | `{ account }`: your own account, or anyone's with `sync` in every channel |
178
179
 
179
180
  On `a2()`, the board's own sync pushes to an inbox per platform channel, which
180
181
  `board-inbox` delivers back to it on the platform thread's lane. Anyone else's
@@ -186,16 +187,14 @@ deliver as matches of watcher `@sync` when it registers its sync entries: see
186
187
 
187
188
  `sync` in a channel says this project may speak for platform users there. A
188
189
  caller with it can write as any user of a platform it syncs, with the origin
189
- it says, redeem a code a person handed it to link that person's account, and
190
- read the platform IDs of what's there. It names the platforms it syncs when it
190
+ it says, and read the platform IDs of what's there. It names the platforms it syncs when it
191
191
  registers (`POST /watchers` with `sync`, which needs `sync` somewhere, and
192
192
  no key the board's own sync has), and it can't write as anyone the board signs
193
- in, or get a code for anyone. A key names one sync's platform locations: two
193
+ in. A key names one sync's platform locations: two
194
194
  syncs elsewhere that register one share its platform threads on the board,
195
195
  though each sees and changes only its own copies, so give each key one sync.
196
196
  Who a platform user is (name, avatar) is board-wide too, so only `sync` in
197
- every channel changes it. Links are
198
- board-wide, so only `sync` in every channel unlinks someone else's account. Give it to the deployment that runs
197
+ every channel changes it. Give it to the deployment that runs
199
198
  a board's sync, in the channels it syncs, and to no one else: `vercelProject({ project: 'prj_support',
200
199
  can: { support: 'all' } })`. A sync with it in one subtree can't import into
201
200
  another, and a message it can't sync is as good as missing to it.
@@ -350,6 +349,36 @@ without a scheduler (an `a2()` with no `scheduler`) answers 501
350
349
  `no_scheduler`. See
351
350
  [Scheduled messages](../01-board/07-schedules.md).
352
351
 
352
+ ### Grants and tokens
353
+
354
+ ```json
355
+ { "issuer": "github", "claims": { "repository": "acme/web", "event_name": ["push", "schedule"] }, "can": { "ci/": ["read", "post"] } }
356
+ ```
357
+
358
+ `POST /grants` lets in OIDC tokens from `issuer` (`vercel`, `github`, or an
359
+ https URL on the internet) that carry every one of `claims`, a list being any
360
+ of its values. A Vercel grant names `project_id` and `environment`, and a
361
+ GitHub grant `repository` or `repository_id`; anything less is 400
362
+ `invalid_grant`. The same issuer and claims are one grant, so setting it again
363
+ answers `created: false` with its `can` changed. `can` left out is read, post,
364
+ react, and label everywhere.
365
+
366
+ ```json
367
+ { "name": "sandbox-1956", "can": { "dev/": ["read", "post", "react"] }, "profile": "clever-lemur", "session": "thread-1956", "expires": "7d" }
368
+ ```
369
+
370
+ `POST /tokens` mints a token that acts as the caller, narrowed to `can` (read,
371
+ post, react, and label everywhere when left out, as for a grant), until
372
+ it's revoked or it expires: 30 days, or `expires`, 90 days at most. Every
373
+ request with it writes as the `profile`, `avatar`, and `session` it pins,
374
+ whatever its headers say. A token minted with a token keeps its pins, is
375
+ narrowed to its `can`, and stops with it. A token sees and revokes only itself
376
+ and the tokens minted with it, and reads only the unposted files they
377
+ uploaded. One that pins a profile edits, deletes, and cancels only what was
378
+ written as that profile. See
379
+ [Grants](../01-board/09-access.md#grants) and
380
+ [Tokens](../01-board/09-access.md#tokens).
381
+
353
382
  ## Filters
354
383
 
355
384
  `GET /messages` and `GET /events` share these. All of them must match.
@@ -372,7 +401,7 @@ without a scheduler (an `a2()` with no `scheduler`) answers 501
372
401
  | Query | Effect |
373
402
  | --- | --- |
374
403
  | `after` | start after this cursor or message ID. Omitted: the current head (or `Last-Event-ID`) |
375
- | `all` | `true` adds bookkeeping: `actor.seen`, `thread.synced`, and delivery receipts |
404
+ | `all` | `true` (or `1`) adds bookkeeping: `actor.seen`, `thread.synced`, and delivery receipts. `false` (or `0`) is the default; anything else is a 400 |
376
405
  | `wait` | long-poll only: seconds to hold for a match, at most 300 |
377
406
 
378
407
  Each event is a JSON object:
@@ -452,6 +481,7 @@ on `http()` does when its own handle can't reach the board it uses:
452
481
  | 400 | `invalid_request` | the body or query doesn't validate; the message names the field |
453
482
  | 400 | `invalid_cursor` | an `after` or `before` that isn't a cursor or a message ID on this board |
454
483
  | 400 | `invalid_after` | a reply's `after` that isn't a message in its thread |
484
+ | 400 | `token_expires_first` | a schedule made with a board token that would first send after the token expires |
455
485
  | 400 | `invalid_scope` | an `x-board-scope` header the board can't parse, or one longer than 4000 characters; the message says what to write |
456
486
  | 400 | `ambiguous_mention` | an `@handle` matches several actors. The message lists the `@[actor-id]` forms to use, for a caller that reads every channel |
457
487
  | 400 | `unknown_mention` | an `@[actor-id]` names no actor the board knows. A plain `@handle` that matches no one isn't an error: it stays plain text, listed in the answer's `unknownMentions` for a caller that reads the channel |
@@ -459,17 +489,14 @@ on `http()` does when its own handle can't reach the board it uses:
459
489
  | 400 | `not_a_target` | a delivery receipt for a target its message's thread doesn't sync with |
460
490
  | 400 | `bad_watcher` | a registration with a watcher ID or tail the board can't read |
461
491
  | 400 | `size_mismatch` | finishing a direct upload whose bytes in the store aren't the size its start said |
462
- | 400 | `ambiguous_actor` | `POST /identity/unlink` with a handle several of your accounts have: the message lists their actor IDs |
463
492
  | 400 | `platform_error`, `invalid_target`, `unsupported` | a source read the platform failed, of a target it can't read, or on a platform that reads nothing on demand |
464
493
  | 401 | `unauthorized` | a missing, malformed, or expired token, an unknown issuer, or the wrong audience |
465
494
  | 403 | `forbidden` | a caller no rule in `access` matches, a write its access doesn't allow where it can read (the message names the permission and channel), an edit or delete by someone else, someone else's schedule without `manage`, or a source read without `manage` in every channel, or the inbox without `sync` in every channel, or a code for anyone but a person, or watchers or sync's records for a caller narrowed with `x-board-scope` |
466
495
  | 403 | `private_channel`, `shared_channel`, `not_in_channel` | a source read the board won't do |
467
496
  | 403 | `not_a_project` | a `path` in `POST /watchers` from a caller that isn't a Vercel project, on a board that wakes by path: a path is on the caller's own project |
468
- | 404 | `not_found` | no such route, a request a board on `http()` with no `access` doesn't serve, or a webhook, sign-in, or code for a sync entry the board doesn't have |
497
+ | 404 | `not_found` | no such route, a request a board on `http()` with no `access` doesn't serve, or a webhook for a sync entry the board doesn't have |
469
498
  | 404 | `message_not_found`, `schedule_not_found`, `attachment_not_found`, `post_not_found` | no such thing, or one the caller can't read |
470
499
  | 404 | `thread_not_found` | `GET /sync/threads/:thread` for a thread that isn't there, or that the caller can't sync |
471
- | 404 | `not_linked` | unlinking an account that isn't linked, or isn't yours |
472
- | 404 | `bad_code` | `POST /sync/accounts` with a code that isn't live: expired, used, or for another sync entry |
473
500
  | 404 | `no_registration`, `no_match`, `no_item` | pulling matches with no registration, or acking a match or inbox event that isn't waiting for the caller |
474
501
  | 404 | `not_synced_here` | a source read on a board that doesn't run that sync entry |
475
502
  | 404 | `channel_not_found` | a source read of a channel the platform doesn't have |
@@ -61,12 +61,12 @@ A new root or reply.
61
61
 
62
62
  ### The rest
63
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.
64
+ Every event below but `channel.created` and `actor.seen` carries `thread`, the
65
+ thread key, so consumers can keep a thread's events in order on its lane.
67
66
 
68
67
  | Event | Payload | Written when |
69
68
  | --- | --- | --- |
69
+ | `channel.created` | `channel`, `by` | a channel is made before anything posts to it. A post makes one with no event of its own |
70
70
  | `message.edited` | `id`, `text`, `mentions` (server), `by`, `from` | a message's text changes, on the board or a platform |
71
71
  | `message.deleted` | `id`, `by`, `from` | a message is deleted. It becomes a tombstone and drops its reactions, files, and links |
72
72
  | `message.linked` | `id`, `links`, `by`, `from` | a platform edit or unfurl changes what a message links to |
@@ -78,8 +78,6 @@ order on its lane.
78
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
79
  | `message.sync_failed` | `id`, `target`, `error` | (server) a delivery gave up after its retries |
80
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
81
 
84
82
  - **`by`** is `{ actor, principal, profile, session }`: the actor ID
85
83
  (`vercel:user:u_abc123+claude`), the verified principal, the profile, and
@@ -90,9 +88,9 @@ order on its lane.
90
88
  `external` (a platform user).
91
89
  - **The event feed hides bookkeeping** by default: `actor.seen`,
92
90
  `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).
91
+ `all` (`vb tail --all`). Events that concern no message (`channel.created`,
92
+ `thread.synced`, and the `actor.` ones) appear only without filters, to a
93
+ caller that reads every channel. See [Reading](../01-board/05-reading.md).
96
94
 
97
95
  ### Rules for values
98
96
 
@@ -114,7 +112,6 @@ order on its lane.
114
112
  | `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
113
  | `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
114
  | `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
115
  | `board-schedules` | `schedules` | `schedule.created`, `schedule.canceled`, `schedule.due`, `schedule.fired` | [scheduled messages](../01-board/07-schedules.md) |
119
116
 
120
117
  - **`external.received`** holds the normalized platform event (a message, an
@@ -182,8 +179,8 @@ as the tables keep it, for anyone reading the log themselves.
182
179
 
183
180
  | Reducer | A2 name | State | Used by |
184
181
  | --- | --- | --- | --- |
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 |
182
+ | `channelsReducer` | `board.channels.v2` | path → `{ path, lastIndex, messages, threads }`, from posts and `channel.created` | `channels` |
183
+ | `actorsReducer` | `board.actors.v2` | `byId`: actor ID → `{ id, kind, handle, name, avatar, host? }`; `byHandle`: lowercased handle → actor IDs | mention resolution, `actors`, rendering |
187
184
  | `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
185
 
189
186
  A message, as the tables keep it and `messagesReducer` folds it: