slotlock 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,8 +10,10 @@ TypeScript library.
10
10
  Slotlock 0.2.0 can occupy less time than an event was written with, so another booking can take the
11
11
  rest: around daylight-saving changes, in any time zone that has them, and at the end of some series.
12
12
  The library and `slotlock serve` are both affected (the last case below is in the library's
13
- iCalendar readers only); the 0.1 releases are older still and unsupported. 0.3.0 fixes all six
14
- cases; while you run 0.2.0, do not rely on it alone to refuse bookings in them.
13
+ iCalendar readers only); the 0.1 releases are older still and unsupported. 0.3.0, released on
14
+ 2026-10-27, fixes all six cases: upgrade (`npm install slotlock@0.3.0` or
15
+ `ghcr.io/pilotaai/slotlock:0.3.0`, then `slotlock migrate`, as the [changelog](CHANGELOG.md) says).
16
+ While you run 0.2.0, do not rely on it alone to refuse bookings in them.
15
17
 
16
18
  - A recurring event whose first occurrence spans a daylight-saving change gets that occurrence an
17
19
  hour short: in spring, an hour of the written time is free to book.
@@ -75,7 +77,12 @@ docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/
75
77
  | `http://localhost:8080/a2a` | A2A 1.0, same token; card at `/.well-known/agent-card.json` |
76
78
  | `http://localhost:8080/v1/…` | [REST](#rest-and-openapi): the tools, and the [application API](#application-api) for admin keys; described at `/openapi.json` |
77
79
  | `http://localhost:8080/healthz` | Readiness, including the database |
80
+ | `http://localhost:8080/metrics` | Prometheus metrics, with `Authorization: Bearer <SLOTLOCK_METRICS_TOKEN>`; off without it |
78
81
 
82
+ - Metrics count calls by protocol, operation and outcome (`slotlock_calls_total`, with their
83
+ duration in `slotlock_call_duration_seconds`), confirmations, subscriptions, refusals, maintenance
84
+ and webhook deliveries. No label names a tenant, key or client. `createSlotlockMetrics` builds
85
+ them for a server of your own, from its hooks.
79
86
  - The server runs as a role that owns nothing and cannot bypass row-level security; a one-off
80
87
  `migrate` service applies the schema as the owner.
81
88
  - `SLOTLOCK_AVAILABILITY` sets the default bookable hours. A resource can have its own
@@ -108,6 +115,9 @@ docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/
108
115
  and the rest wait their turn, so a tenant's writes waiting on its own locks leave the others to
109
116
  other tenants and key lookups; a server with one tenant can set `tenant_transactions=10`. A
110
117
  request whose transaction waits more than 5 seconds for a lock gets 503 `server_busy`.
118
+ With more than one server, `SLOTLOCK_SHARED_LIMITS=on` shares the tenant budgets (`tenant_*`,
119
+ `app_*`) through the database, so N servers allow a tenant its rate once, not N times; each server
120
+ checks its own buckets first, and goes on with them alone if the database cannot be reached.
111
121
  `SLOTLOCK_LIMITS` changes the limits; behind a proxy, set `SLOTLOCK_TRUSTED_PROXY_HEADER` to the field it puts the
112
122
  client's address in (`CF-Connecting-IP` behind Cloudflare) before the server takes traffic, or
113
123
  every client shares the proxy's budget: the server logs a `client_address_field_ignored` warning
@@ -131,6 +141,37 @@ A rule is `rrule`, `startMinutes` and `durationMinutes` here and in `SLOTLOCK_AV
131
141
  REST application API spells every field in snake_case, so there it is `rrule`, `start_minutes` and
132
142
  `duration_minutes`. `slotlock --help` lists every setting.
133
143
 
144
+ #### Booking rules
145
+
146
+ A resource can also say what a booking on it must satisfy:
147
+
148
+ ```sh
149
+ slotlock rules set '{"minNoticeMinutes":120,"slotStepMinutes":30,"turnaroundMinutes":30,"enforceHours":true}' --resource vehicle-42
150
+ slotlock rules clear --resource vehicle-42
151
+ ```
152
+
153
+ | Rule | A booking made now |
154
+ | --- | --- |
155
+ | `minNoticeMinutes` | starts at least this far ahead |
156
+ | `maxAdvanceDays` | starts at most this far ahead |
157
+ | `slotStepMinutes` | starts on a step from local midnight in the resource's time zone |
158
+ | `turnaroundMinutes` | keeps this long free after it: its trailing buffer, which the database enforces |
159
+ | `enforceHours` | lies inside the resource's bookable hours |
160
+ | `checkInMinutes` | is claimed (`checkInReservation`) this long after its start, or maintenance releases it |
161
+ | `requireCertain` | lies in time every calendar sync recording coverage for the resource has read within a day |
162
+
163
+ - Holds, reservations, moves and agents' events are checked when they are made, by the database's
164
+ clock; a refusal names the rule (`booking_rule_violated` with `rule` in the store,
165
+ `booking_rule_min_notice`, `_max_advance`, `_slot_step`, `_outside_hours` or `_uncertain` to an
166
+ agent). Under `requireCertain`, a search (the store's and the tools') needs those syncs' coverage
167
+ too, read within a day, of the slot and its turnaround, so it offers no slot they leave unproven.
168
+ - A series is judged on its first occurrence for notice and advance, and on every occurrence for
169
+ step and hours. Imported calendar events are not checked: they happened elsewhere.
170
+ - Searches (`slotlock_find_next_available`, `slotlock_find_available_slots`, `findNextAvailableFor`)
171
+ offer only slots the rules allow, so an offered slot is one the write accepts.
172
+ - The REST application API sets them with `PUT /v1/resources/{resource_id}/booking-rules`, in
173
+ snake_case; the store with `setResourceBookingRules`.
174
+
134
175
  ### Without Docker
135
176
 
136
177
  With Node.js 22.12+ and PostgreSQL 16 or newer, run each command as `npx slotlock` after
@@ -211,10 +252,33 @@ resources, so you can give each person API keys without running `slotlock key cr
211
252
  3. Run `slotlock serve` and open `<SLOTLOCK_PUBLIC_URL>/dashboard`. With Compose, set the four in
212
253
  `.env` (empty, the default, leaves the dashboard off) and run `docker compose up --detach`.
213
254
 
255
+ - Its pages: an Overview with the coming week's bookings, events and live holds across every
256
+ resource (each in its resource's time zone) and the latest changes agents made, named by key;
257
+ Resources, each with its own page for bookable hours, booking rules, pool, coming bookings and
258
+ activity; Waitlist, to see who waits and remove an entry; API keys, with the MCP and A2A
259
+ endpoints; Webhooks, to add, delete and re-enable endpoints and see which are failing; and
260
+ Settings.
261
+ Everything on them is read from the tenant's own data. `SLOTLOCK_DASHBOARD_NAME` sets what the
262
+ dashboard calls the deployment (Slotlock by default).
214
263
  - Each person gets a tenant of their own, `github:<user id>`: the keys, resources and bookings in it
215
264
  are theirs alone. Keys made with `slotlock key create` act in `SLOTLOCK_TENANT` instead.
216
- - Sign-in asks GitHub for no scope, uses `state` and PKCE, and keeps nothing from GitHub but the user
217
- id and login: the GitHub token is used once and dropped.
265
+ - **For organizations instead of people**, set `SLOTLOCK_DASHBOARD_ORGS` to the GitHub organization
266
+ ids the dashboard serves (`gh api orgs/<login> --jq .id`). Each is then one tenant,
267
+ `github-org:<id>`, that its members share: keys, resources and hours. A person signs in to the
268
+ served organizations they are an active owner or member of (a billing manager is neither),
269
+ switching between them when there are several; someone in none of them is refused. Every member
270
+ has the same power: each can create, rotate and revoke the organization's keys, and the key list
271
+ says who created each one. Membership is read at each sign-in, so a person who leaves keeps
272
+ the dashboard until their session ends (12 hours at most), and keys they made work until revoked.
273
+ Removing an id from the list signs its sessions out and stops its keys at once. An organization
274
+ that restricts OAuth apps must approve the dashboard's app (its Settings, Third-party access), or
275
+ GitHub hides the membership. `SLOTLOCK_DASHBOARD_USERS` still says who may sign in at all, and a
276
+ key records the person who created it (`github:<user id>`): erasing that person's own tenant
277
+ leaves it on keys they made in an organization, which go with the organization's erasure. Only the
278
+ first 100 memberships GitHub lists, and the first 20 served organizations by login, are read.
279
+ - Sign-in uses `state` and PKCE, and keeps nothing from GitHub but the user id and login (and, with
280
+ organizations, the served ones the person belongs to): the GitHub token is used once and dropped.
281
+ It asks for no scope, or `read:org` with organizations.
218
282
  - A session is a signed, HttpOnly cookie that lasts 12 hours. The allowlist is checked on every
219
283
  request, dashboard and API alike: removing an id and restarting the server signs that person out
220
284
  and stops every key in their tenant at once.
@@ -295,6 +359,9 @@ this one and read its session secret, which signs in as any tenant.
295
359
  every client together, and the Worker logs a warning: add the ceiling before lowering that
296
360
  binding's limit to one client's. The example configuration also removes query strings (a
297
361
  callback's OAuth code) from the URLs Workers Logs keep.
362
+ - With organizations, set `SLOTLOCK_DASHBOARD_ORGS` on both the Worker and the server: the server
363
+ takes an organization's keys only while its own list names it, whether or not it serves the
364
+ dashboard.
298
365
  - The server checks a dashboard tenant against `SLOTLOCK_DASHBOARD_USERS` only when it serves the
299
366
  dashboard itself. With the dashboard on Workers, removing a person from the Worker's list signs
300
367
  them out of the dashboard; to stop their keys, revoke them or erase the tenant.
@@ -481,6 +548,25 @@ uses the default, `[]` closes it); `setTenantAvailability({ tenantRef, rules })`
481
548
  of a tenant. `createSlotlockStoreAgentBackend` answers from a resource's own hours and calls
482
549
  `availabilityRules` only for resources without them.
483
550
 
551
+ Waitlists: `joinWaitlist({ tenantRef, resourceId | pool, start, end, holderRef, offerTtlMs })`
552
+ waits for a window that is taken now. When it comes free (a cancel, a released or expired hold, a
553
+ no-show), `offerWaitlist` gives it to the entries in the order they joined: the first whose window
554
+ is free, on its resource or the first free member of its pool, gets a hold for its holder, and the
555
+ feed says so (`waitlist.offered`, with `hold_id`). Confirm the hold to book it; let it go and the next
556
+ is offered. Cancels, releases and moves, whether an agent (MCP, A2A) or the application API makes
557
+ them, offer at once; maintenance offers what lapses on its own (an expired hold, a no-show) and
558
+ expires entries whose window has started (`waitlist.expired`). With `checkInMinutes` in a
559
+ resource's booking rules, maintenance releases a booking made since the rule was set that nobody
560
+ checked in that long after its start, or after it was made for a walk-in (`reservation.no_show`),
561
+ and the waitlist is offered its time. Check-in opens an hour before the start; moving a booking to
562
+ another start clears it. Leaving the waitlist lets an offer not yet confirmed go with it.
563
+
564
+ Calendar tools speak the standards: `emitVAvailability({ rules, timezone, uid })` writes rules as an
565
+ iCalendar VAVAILABILITY (RFC 7953) and `parseVAvailability(text)` reads one back as
566
+ `{ timezone, rules }`; `toJSCalendarEvent(event)` answers a calendar event as a JSCalendar Event
567
+ (RFC 8984). The API serves them at `/v1/resources/{resource_id}/availability/ical` and
568
+ `/v1/tenant/events/{event_id}/jscalendar`.
569
+
484
570
  Writing rules:
485
571
 
486
572
  - Create with `expectedRevision: 0`; update with the revision you last read and a new idempotency
@@ -780,6 +866,12 @@ export async function startCalendarServer(config: CalendarServerConfig) {
780
866
  | `slotlock_list_resources` | Resources visible to the tenant | read-only |
781
867
  | `slotlock_get_free_busy` | Busy intervals plus coverage certainty | read-only |
782
868
  | `slotlock_find_next_available` | Earliest *certain* slot across resources | read-only |
869
+ | `slotlock_find_available_slots` | Up to 50 *certain* slots, earliest first, to offer a choice | read-only |
870
+ | `slotlock_hold_slots` | Hold up to 5 candidates for 30-900 seconds while a person chooses | write |
871
+ | `slotlock_release_slots` | Release a hold and the rest of its group | write |
872
+ | `slotlock_book_together` | Book the same time on 2-20 resources, all or none | write |
873
+ | `slotlock_book_pool` | Book any one free member of a pool (a vehicle class, a room type) | write |
874
+ | `slotlock_get_pool_availability` | How many of a pool's members are free across a window | read |
783
875
  | `slotlock_list_events` | Events the caller created in a window | read-only |
784
876
  | `slotlock_get_event` | One event the caller created | read-only |
785
877
  | `slotlock_create_event` | Create an event of up to 3,660 days (idempotent) | write |
@@ -787,7 +879,33 @@ export async function startCalendarServer(config: CalendarServerConfig) {
787
879
  | `slotlock_delete_event` | Tombstone at an expected revision | write, destructive |
788
880
 
789
881
  A query window is at most 367 days. Each caller sees only the events it created, but everyone's
790
- events count as busy time. The older names `calendar.<verb>` and `calendar_<verb>` still work and
882
+ events count as busy time.
883
+
884
+ An agent offering a person several times holds them first, so none is taken while the person
885
+ decides: `slotlock_hold_slots` holds each free candidate in one group (and lists the taken ones in
886
+ `refused`), and `slotlock_create_event` with one `hold_id` books that time and releases the rest in
887
+ the same transaction. A hold belongs to the agent that took it: another agent can neither book nor
888
+ release it. One agent may have 20 live holds at once, holding at most 2,160 hours together in
889
+ `slotlock serve` (`SLOTLOCK_LIMITS`: `holder_holds`, `holder_held_hours`; `maxHoldsPerHolder` and
890
+ `maxHeldHoursPerHolder` in `createSlotlockStore`); past either it is refused, `hold_limit_reached` or
891
+ `held_time_limit_reached`. Unbooked holds expire by the database clock.
892
+ Notice was judged when the time was held, so booking the hold does not judge it again.
893
+
894
+ A booking that needs several resources at once (a van and its driver, a room and its projector)
895
+ uses `slotlock_find_available_slots` with `require_free` (every resource, or any n of them, free
896
+ together, each judged by its own hours and rules) and books with `slotlock_book_together`: one event
897
+ per resource in one transaction, all or none. A refusal names the `resource_id` that refused, on
898
+ MCP and A2A in the error beside its `code`, on REST in the problem. Bundles take their resources
899
+ in one order, so two racing over the same ones end with one booked, never a deadlock.
900
+
901
+ Rentals sell a class, not a plate. Put interchangeable resources in a pool
902
+ (`PUT /v1/resources/{id}/pool`, `setResourcePool`); agents see each resource's `pool`, list a pool's
903
+ members with `slotlock_list_resources` and `pool`, and book the class with `slotlock_book_pool`,
904
+ which takes the first member that is free and allows the booking, in one transaction: a class of
905
+ five sells exactly five for a time, however many race (`pool_full` after that). To see how many are
906
+ free, `slotlock_get_pool_availability` splits a window where that number changes; to see which,
907
+ search the members with `slotlock_find_available_slots` and `require_free: 1`: each slot names the
908
+ members free in `resource_ids`. `GET /v1/pools` lists the pools with their capacity. The older names `calendar.<verb>` and `calendar_<verb>` still work and
791
909
  are no longer advertised.
792
910
 
793
911
  ### Errors
@@ -923,6 +1041,12 @@ never cached. W3C `traceparent`, `tracestate` and `baggage` reach your backend a
923
1041
  | `GET /v1/resources` | `slotlock_list_resources` |
924
1042
  | `GET /v1/free-busy` | `slotlock_get_free_busy` |
925
1043
  | `GET /v1/next-available` | `slotlock_find_next_available` |
1044
+ | `GET /v1/available-slots` | `slotlock_find_available_slots` |
1045
+ | `POST /v1/slot-holds` | `slotlock_hold_slots` |
1046
+ | `DELETE /v1/slot-holds/{hold_id}` | `slotlock_release_slots` |
1047
+ | `POST /v1/bundles` | `slotlock_book_together` |
1048
+ | `POST /v1/pool-bookings` | `slotlock_book_pool` |
1049
+ | `GET /v1/pool-availability` | `slotlock_get_pool_availability` |
926
1050
  | `GET /v1/events` | `slotlock_list_events` |
927
1051
  | `POST /v1/events` | `slotlock_create_event` |
928
1052
  | `GET /v1/events/{event_id}` | `slotlock_get_event` |
@@ -937,7 +1061,18 @@ curl "http://localhost:8080/v1/resources" -H "Authorization: Bearer $SLOTLOCK_AP
937
1061
  `resource_ids=a&resource_ids=b`); `POST`, `PUT` and `PATCH` take a JSON body. Ids are in the path;
938
1062
  percent-encode one that holds `/`.
939
1063
  - Keys, scopes, `authorize` and the rate limit apply as over MCP. The body is the tool's output, or
940
- `{"error":{"code":…}}` with the matching HTTP status.
1064
+ an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem (`application/problem+json`) with
1065
+ the matching HTTP status: `type` (a tag URI, one per code), `title`, `status`, `code`, and
1066
+ `error.code` as well, the body earlier clients read. An `overlap` names the `resource_id`, `start`
1067
+ and `end` it refused. A `Link: <…>; rel="describedby"` header points at these docs.
1068
+ - A write's `idempotency_key` may come as the `Idempotency-Key` header instead
1069
+ ([draft 08](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)): a
1070
+ quoted Structured Field String (`Idempotency-Key: "booking-BK-42"`) or the bare key, equal to the
1071
+ argument when both are sent. A write that needs one and gets neither answers
1072
+ `400 idempotency_key_required`; the same key with another request, `422 idempotency_conflict`.
1073
+ An event write's key is kept 30 days (or `SLOTLOCK_RETENTION_DAYS`), then removed by maintenance,
1074
+ after which the key is new; a
1075
+ hold's until the hold would have expired, is released or is confirmed.
941
1076
  - REST cannot ask a person, so a write listed in `confirmation` answers `428 confirmation_required`.
942
1077
  - Free/busy and the next-available search read each resource they name once, however often it is
943
1078
  named, and 10,000 busy intervals in all; the next-available search also expands 100,000 bookable
@@ -999,7 +1134,7 @@ curl -X PUT "http://localhost:8080/v1/reservations/BK-42" \
999
1134
  `DELETE /v1/reservations/{hold_id}?revision=2`.
1000
1135
  - `POST /v1/holds` and `DELETE /v1/tenant/events/…` take a retry identity as the `Idempotency-Key`
1001
1136
  header or as `idempotency_key`. A retried hold answers the hold the first request made; the same
1002
- key with another request is `409 idempotency_conflict`. Other routes are safe to retry as they
1137
+ key with another request is `422 idempotency_conflict`. Other routes are safe to retry as they
1003
1138
  are, and refuse the header (`400 idempotency_key_not_supported`).
1004
1139
  - Lists page by `limit` (up to 100) and `cursor`, the `next_cursor` of the page before, and read a
1005
1140
  window of at most 367 days.
@@ -1017,6 +1152,58 @@ curl -X PUT "http://localhost:8080/v1/reservations/BK-42" \
1017
1152
  `{ backend: createSlotlockStoreApplicationBackend(store, { maxTenantResources }), authorize }`.
1018
1153
  A principal with `scopes` needs `admin`; then `authorize` decides.
1019
1154
 
1155
+ ### Changes and webhooks
1156
+
1157
+ Every committed change to a tenant's holds, bookings and calendar events is recorded in the same
1158
+ transaction (by a database trigger, so no write path skips it), and nothing is recorded that did
1159
+ not commit. A change carries ids, times and states, never titles, descriptions or attendees.
1160
+
1161
+ - `GET /v1/changes?cursor=…` lists them oldest first as [CloudEvents 1.0](https://cloudevents.io)
1162
+ (`io.pylota.slotlock.hold.created`, `.reservation.cancelled`, `.event.updated`, …). Follow
1163
+ `next_cursor`, which comes back even when nothing is new: it never skips a change, even one whose
1164
+ transaction commits after a later one's. Changes are kept 30 days (`SLOTLOCK_RETENTION_DAYS`,
1165
+ `retentionDays` in `createSlotlockStore`, 1-3,650, for `serve` and `maintain` alike); a cursor
1166
+ that a pruned change came after, whichever server pruned it, answers `410 cursor_expired`. A change keeps who made
1167
+ it (`actor`) and a booking's or waitlist entry's reference: keep references opaque, not emails,
1168
+ when the feed is kept long. A shorter retention never shortens how long a retried write is
1169
+ answered as the first was: that stays at least 30 days.
1170
+ - `POST /v1/webhooks` with an https `url` (and `event_types`, optionally) sends each change from
1171
+ then on as a [Standard Webhooks](https://www.standardwebhooks.com) request: `webhook-id` (the
1172
+ change's id, the same on every retry), `webhook-timestamp` and `webhook-signature`, keyed by the
1173
+ `whsec_` secret the call returns once. Verify it with any Standard Webhooks library, and use
1174
+ `webhook-id` to drop a repeat: delivery is at least once.
1175
+ - Changes go to each webhook in order, one at a time, until one fails; that one is retried after
1176
+ 5 seconds, 5 minutes, 30 minutes, 2, 5, then every 10 hours. After a day of failures, or a `410`,
1177
+ the webhook is disabled and the feed records `webhook.disabled`; `POST /v1/webhooks/{id}/enable`
1178
+ resumes it where it stopped, or answers `409 changes_pruned` when what it missed is older than the
1179
+ feed keeps (add it again and read what it missed from `/v1/changes`). The feed keeps every change
1180
+ an enabled webhook has still to receive, however old. At most 10 webhooks per tenant.
1181
+ - Each request has 15 seconds in all, and a tenant's pass 30 seconds; tenants are delivered 8 at
1182
+ a time, so a slow endpoint holds up only its own tenant.
1183
+ - History reads the same record backwards, with who made each change (the API key or agent) and,
1184
+ for a change of an existing booking or event, what it was before:
1185
+ `GET /v1/resources/{id}/history`, `GET /v1/reservations/{reference}/history`,
1186
+ `GET /v1/tenant/events/{id}/history`, newest first, paged by `before`. In the library, pass
1187
+ `actor` to `withTenant` and read `listHistory`. It is kept as long as the feed and erased with
1188
+ the tenant.
1189
+ - When time zone rules change (a new tzdata), a booking's instant and the wall time it was made for
1190
+ part: Winnipeg's 09:00 is no longer the instant stored. Slotlock keeps both, and maintenance finds
1191
+ future one-off events whose wall time moved: it reports each once (`event.timezone_shifted`, with
1192
+ the instant it has and the one its wall time now means). The library's `followZoneRules` option
1193
+ to `maintainSlotlockTenant` moves them instead, as a new revision, unless that overlaps another
1194
+ booking (`event.timezone_conflict`; it stays, for a person or an agent to decide). `/healthz`
1195
+ reports the `tzdata` the server runs.
1196
+ - Feed health: `GET /v1/resources/{id}/coverage` shows each source's window, when it was last read,
1197
+ its last error (`POST /v1/resources/{id}/coverage/{source}/failure` records one) and whether it is
1198
+ stale (not read for a day, so the time it covers is unproven, never free). A source that goes stale
1199
+ is put on the feed once, as `coverage.stale`, until it is read again. Retire a sync you have
1200
+ disconnected or replaced with `DELETE /v1/resources/{id}/coverage/{source}` (`deleteCoverage`):
1201
+ nothing waits on it after that, `requireCertain` included.
1202
+ - `slotlock serve` delivers (`SLOTLOCK_WEBHOOKS`): never to an address on a private network, which
1203
+ it checks when it connects, and following no redirect. `private` allows those, for testing.
1204
+ - In the library: `listChanges`, `createWebhookEndpoint` and the rest are store methods, and
1205
+ `startSlotlockWebhookDelivery` with `deliverSlotlockWebhooks` runs delivery in your own process.
1206
+
1020
1207
  ## Connect an agent
1021
1208
 
1022
1209
  MCP and A2A are wire protocols, so an agent in any language connects with its own MCP or A2A client
@@ -1125,7 +1312,7 @@ export async function openLiveCalendar(options: LiveCalendarOptions) {
1125
1312
 
1126
1313
  ```sh
1127
1314
  claude mcp add --transport http slotlock https://calendar.example.com/slotlock/mcp \
1128
- --header "Authorization: Bearer $SLOTLOCK_TOKEN"
1315
+ --header "Authorization: Bearer $SLOTLOCK_API_KEY"
1129
1316
  ```
1130
1317
 
1131
1318
  **Claude API** (MCP connector, `anthropic-beta: mcp-client-2025-11-20`; the server must be public
@@ -1186,6 +1373,18 @@ Read-only tools link to `ui://slotlock/calendar`, a self-contained calendar view
1186
1373
  supporting MCP Apps (`io.modelcontextprotocol/ui`) render in a sandbox. It makes no network requests
1187
1374
  and never writes.
1188
1375
 
1376
+ A person can pick a slot in it. After `slotlock_find_available_slots` or a certain
1377
+ `slotlock_find_next_available`, the view shows a strip of days and each day's times by morning,
1378
+ afternoon and evening, or the one next available time. Picking a time and pressing *Book this time*
1379
+ sends one chat message naming the resource id and the slot's start and end. The agent books it with
1380
+ `slotlock_create_event`, through confirmation if `SLOTLOCK_CONFIRM_WRITES` asks for it. The message
1381
+ carries nothing else from the result. If the host refuses the message, the view shows the text to
1382
+ send instead. Write tools are marked `visibility: ["model"]`, so a host never lets a view call them.
1383
+
1384
+ The view takes its look from the host: its light or dark theme and its standard MCP Apps style
1385
+ variables (colours, fonts, sizes). It shows times in the person's locale, clock and time zone, with
1386
+ a 12h/24h switch.
1387
+
1189
1388
  ## Operations
1190
1389
 
1191
1390
  Every tenant needs maintenance at least daily: it extends recurring events to the rolling horizon
@@ -1232,12 +1431,14 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1232
1431
  (provider and sync authority) are kept until you erase the tenant. After the window a retried
1233
1432
  command is evaluated afresh under its expected revision, and a pruned agent identity can be
1234
1433
  created again. `retentionDays` (1-3650) changes the window; keep it longer than any client retries.
1235
- - **Erasure.** Include Slotlock resources, reservations, tombstones, archives, event content,
1236
- occurrences, commands and coverage rows in your tenant-erasure workflow and delete order, and
1237
- delete the tenant's API keys with `createSlotlockApiKeyStore(sql).erase({ tenantRef })`. Only
1238
- their SHA-256 digests stay, with nothing about the tenant, so an erased key is never accepted
1239
- again. The dashboard's records hold no tenant and expire within 12 hours; a form record carries a
1240
- SHA-256 digest of the GitHub user id that sent it.
1434
+ - **Erasure.** `slotlock tenant erase --confirm <tenant>`, or the store's `eraseTenant({ tenantRef })`,
1435
+ deletes everything the tenant owns in one transaction, waiting for its transactions in flight:
1436
+ resources, holds, bookings, events with their attendees and reminders, cancellations,
1437
+ idempotency records, coverage, API keys, and a personal tenant's dashboard records. Call it from
1438
+ your own erasure workflow; nothing is deleted table by table. Only the API keys' SHA-256 digests
1439
+ stay, with nothing about the tenant, so an erased key is never accepted again. The dashboard's
1440
+ other records hold no tenant and expire within 12 hours; a form record carries a SHA-256 digest
1441
+ of the GitHub user id that sent it, and an accepted write confirmation a digest of its nonce.
1241
1442
 
1242
1443
  ### Production checklist
1243
1444
 
@@ -1254,13 +1455,21 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1254
1455
  9. Size live updates: at most `maxTotal` backend reads per `pollIntervalMs`.
1255
1456
  10. Give each agent its own API key with the narrowest scope and an expiry.
1256
1457
  11. After each deployment, check it end to end with a short-lived admin key, then revoke the key:
1257
- `SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs` checks health,
1458
+ `SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs`, from a clone
1459
+ of the repository, checks health,
1258
1460
  discovery, MCP, A2A, holds and reservations (in a resource named `slotlock-smoke`, 400 days
1259
1461
  ahead, cancelled after) and the 401, 409, 413 and 415 refusals.
1260
1462
  12. Rate-limit every public server. `slotlock serve` does it per client address, key, tenant and
1261
1463
  server (`SLOTLOCK_LIMITS`); behind a proxy, set `SLOTLOCK_TRUSTED_PROXY_HEADER` and firewall
1262
1464
  the server so nothing reaches it except through that proxy. Embedding the
1263
1465
  agent server, give it a `consumeRateLimit`. Watch serve's `refusals` log line for floods.
1466
+ 13. Bound how long a transaction may run. A store over your own transaction (a savepoint per call)
1467
+ keeps its locks and its snapshot for as long as yours runs, and a hold you confirm inside it is
1468
+ judged by the statement's clock, not the transaction's. On PostgreSQL 17 and later, give the
1469
+ serving role a `transaction_timeout` above your longest legitimate transaction
1470
+ (`ALTER ROLE slotlock_app SET transaction_timeout = '60s'`): PostgreSQL ends one that runs
1471
+ longer, rolling back what it did, and closes its connection (the pool opens another). On 16, set `idle_in_transaction_session_timeout`
1472
+ and `statement_timeout`, which bound idle gaps and single statements but not the whole.
1264
1473
 
1265
1474
  ## Supported versions
1266
1475
 
@@ -1278,8 +1487,8 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1278
1487
  Slotlock is availability maths, holds and reservations, events with recurrence, coverage-aware
1279
1488
  free/busy, tenant isolation, iCalendar interchange, MCP and A2A with human confirmation and live
1280
1489
  updates, an MCP Apps view, REST with the application API, scoped API keys, a GitHub sign-in
1281
- dashboard, maintenance, and clients for five languages. [Hosted Slotlock](docs/hosted/README.md)
1282
- runs it for you. Provider sync (Google, Microsoft, CalDAV), notifications to people, and a human
1490
+ dashboard, maintenance, and clients for five languages. [Hosted Slotlock](docs/hosted/README.md),
1491
+ not open yet, will run it for you. Provider sync (Google, Microsoft, CalDAV), notifications to people, and a human
1283
1492
  calendar app are yours to build around it. What each version promises is in
1284
1493
  [VERSIONING.md](VERSIONING.md).
1285
1494
 
package/agent-server.d.ts CHANGED
@@ -40,7 +40,7 @@ export declare const SLOTLOCK_A2A_TASK_STATES: readonly ["TASK_STATE_UNSPECIFIED
40
40
  * Operation (MCP tool / A2A skill) names. They use only `[a-z_]`, so they are valid tool names for the
41
41
  * Claude Messages API (`^[a-zA-Z0-9_-]{1,128}$`), OpenAI function calling and every MCP host.
42
42
  */
43
- export type SlotlockAgentOperation = 'slotlock_list_resources' | 'slotlock_get_free_busy' | 'slotlock_find_next_available' | 'slotlock_create_event' | 'slotlock_get_event' | 'slotlock_list_events' | 'slotlock_update_event' | 'slotlock_delete_event';
43
+ export type SlotlockAgentOperation = 'slotlock_list_resources' | 'slotlock_get_free_busy' | 'slotlock_find_next_available' | 'slotlock_find_available_slots' | 'slotlock_hold_slots' | 'slotlock_release_slots' | 'slotlock_book_together' | 'slotlock_book_pool' | 'slotlock_get_pool_availability' | 'slotlock_create_event' | 'slotlock_get_event' | 'slotlock_list_events' | 'slotlock_update_event' | 'slotlock_delete_event';
44
44
  /**
45
45
  * Names earlier builds published, which every entry point still accepts and dispatches as the same
46
46
  * operation; nothing advertises them. The dotted names came first, before tool names had to be
@@ -79,7 +79,13 @@ export interface SlotlockAgentInvocationContext {
79
79
  export declare class SlotlockAgentOperationError extends Error {
80
80
  readonly code: string;
81
81
  readonly status: number;
82
- constructor(code: string, status: number);
82
+ /**
83
+ * What the refusal is about, beside its code (the resource a bundle was refused on, say): at most
84
+ * five snake_case names with text values of up to 200 characters; anything else is dropped. Every
85
+ * protocol carries them next to `code`.
86
+ */
87
+ readonly details?: Readonly<Record<string, string>>;
88
+ constructor(code: string, status: number, details?: Record<string, string>);
83
89
  }
84
90
  export interface SlotlockAgentCalendarBackend {
85
91
  listResources(context: SlotlockAgentInvocationContext, input: Record<string, unknown>): Promise<Record<string, unknown>>;
@@ -101,6 +107,25 @@ export interface SlotlockAgentCalendarBackend {
101
107
  name: string | null;
102
108
  timezone: string | null;
103
109
  } | null>;
110
+ /**
111
+ * Optional. Hold up to five candidate slots for this principal, as alternatives in one group
112
+ * (`slotlock_hold_slots`); a tool without it answers `holds_unavailable`.
113
+ */
114
+ holdSlots?(context: SlotlockAgentInvocationContext, input: Record<string, unknown>): Promise<Record<string, unknown>>;
115
+ /** Optional. Book any one free member of a pool (`slotlock_book_pool`), else `pools_unavailable`. */
116
+ bookPool?(context: SlotlockAgentInvocationContext, input: Record<string, unknown>): Promise<Record<string, unknown>>;
117
+ /**
118
+ * Optional. How many of a pool's members are free across a window
119
+ * (`slotlock_get_pool_availability`), else `pools_unavailable`.
120
+ */
121
+ getPoolAvailability?(context: SlotlockAgentInvocationContext, input: Record<string, unknown>): Promise<Record<string, unknown>>;
122
+ /**
123
+ * Optional. Book one interval on several resources in one transaction, all or none
124
+ * (`slotlock_book_together`); a tool without it answers `bundles_unavailable`.
125
+ */
126
+ bookTogether?(context: SlotlockAgentInvocationContext, input: Record<string, unknown>): Promise<Record<string, unknown>>;
127
+ /** Optional. Release one of this principal's holds with its group (`slotlock_release_slots`). */
128
+ releaseSlots?(context: SlotlockAgentInvocationContext, input: Record<string, unknown>): Promise<Record<string, unknown>>;
104
129
  }
105
130
  /**
106
131
  * OAuth 2.0 discovery for the MCP endpoint (MCP 2025-11-25 authorization, RFC 9728). Slotlock only
@@ -120,7 +145,7 @@ export interface SlotlockAgentServerOAuthOptions {
120
145
  resourceDocumentation?: string;
121
146
  }
122
147
  /** The operations that change a calendar; the only ones a confirmation can guard. */
123
- export type SlotlockAgentWriteOperation = Extract<SlotlockAgentOperation, 'slotlock_create_event' | 'slotlock_update_event' | 'slotlock_delete_event'>;
148
+ export type SlotlockAgentWriteOperation = Extract<SlotlockAgentOperation, 'slotlock_create_event' | 'slotlock_update_event' | 'slotlock_delete_event' | 'slotlock_book_together' | 'slotlock_book_pool'>;
124
149
  /**
125
150
  * Human confirmation before a write (MCP 2026-07-28 multi round-trip requests + form elicitation).
126
151
  * A listed write first answers `input_required` with a yes/no form describing the change; it runs
@@ -141,6 +166,18 @@ export interface SlotlockAgentConfirmationOptions {
141
166
  secrets: readonly (string | Uint8Array)[];
142
167
  /** How long a confirmation prompt stays answerable, in seconds: 30-3,600, default 600. */
143
168
  ttlSeconds?: number;
169
+ /**
170
+ * Optional. Records an accepted confirmation, by the nonce its sealed state carries: true the first
171
+ * time, false for one already recorded, which the server then refuses (`confirmation_invalid`) so
172
+ * an acceptance runs its write once. Keep each nonce until `expiresAt`, across every server that
173
+ * shares the secrets (`createSlotlockAcceptanceRecords` does, in Postgres). Without it an accepted
174
+ * state can be sent again until it expires; it names the same principal, write and arguments, so
175
+ * the write's idempotency key or expected revision then decides.
176
+ */
177
+ consumeAcceptance?: (acceptance: {
178
+ nonce: string;
179
+ expiresAt: Date;
180
+ }) => Promise<boolean>;
144
181
  }
145
182
  /**
146
183
  * Live calendar updates over MCP 2026-07-28 `subscriptions/listen`. A client subscribes to calendar
@@ -318,6 +355,7 @@ export type SlotlockAgentOperationDispatchResult = {
318
355
  ok: false;
319
356
  status: number;
320
357
  code: string;
358
+ details?: Readonly<Record<string, string>>;
321
359
  };
322
360
  /** Runtime type guard for the current operation names (legacy dotted names are not included). */
323
361
  export declare function isSlotlockAgentOperation(name: unknown): name is SlotlockAgentOperation;
@@ -376,22 +414,27 @@ export declare function slotlockOpenApi(options?: {
376
414
  application?: boolean;
377
415
  }): Record<string, unknown>;
378
416
  export declare function slotlockAgentTools(): {
379
- name: "slotlock_create_event" | "slotlock_delete_event" | "slotlock_find_next_available" | "slotlock_get_event" | "slotlock_get_free_busy" | "slotlock_list_events" | "slotlock_list_resources" | "slotlock_update_event";
380
- title: "Create resource event" | "Delete resource event" | "Find next available interval" | "Get free-busy" | "Get resource event" | "List calendar resources" | "List resource events" | "Update resource event";
381
- description: "Apply an expected-revision event patch and rematerialize occupancy atomically. With expected_resource_id, the resource you read the event on, an event since moved elsewhere is not found." | "Create an event of up to 3,660 days (a booking, lease or long rental); opaque occurrences use the database overlap arbiter." | "Find the earliest certain interval that satisfies the requested duration (up to the 367-day search window, so a multi-month rental slot can be found)." | "List bounded events intersecting a time window." | "List resources visible to the authenticated tenant principal." | "Read one tenant-scoped event by stable identifier." | "Return privacy-minimized busy intervals and explicit coverage certainty." | "Tombstone an event at an expected revision so stale sync cannot resurrect it.";
417
+ name: "slotlock_book_pool" | "slotlock_book_together" | "slotlock_create_event" | "slotlock_delete_event" | "slotlock_find_available_slots" | "slotlock_find_next_available" | "slotlock_get_event" | "slotlock_get_free_busy" | "slotlock_get_pool_availability" | "slotlock_hold_slots" | "slotlock_list_events" | "slotlock_list_resources" | "slotlock_release_slots" | "slotlock_update_event";
418
+ title: "Book from a pool" | "Book resources together" | "Create resource event" | "Delete resource event" | "Find available slots" | "Find next available interval" | "Get free-busy" | "Get pool availability" | "Get resource event" | "Hold candidate slots" | "List calendar resources" | "List resource events" | "Release held slots" | "Update resource event";
419
+ description: "Apply an expected-revision event patch and rematerialize occupancy atomically. With expected_resource_id, the resource you read the event on, an event since moved elsewhere is not found." | "Book any one member of a pool (a vehicle class, a room type) for a time: members are tried in a fixed order and the first that is free and allows the booking is booked, in one transaction, so a class sells out exactly at its number of members. The event names the member booked. 409 pool_full when none is free; 404 pool_not_found when the pool has no member you may book. A retry with the same idempotency_key answers the same event." | "Book the same time on 2-20 resources at once, all or none: a van with its driver, a room with its projector. If any of them is taken, nothing is booked, and the refusal names that resource_id. Find such a time with slotlock_find_available_slots and require_free. A retry with the same idempotency_key answers the same events." | "Create an event of up to 3,660 days (a booking, lease or long rental); opaque occurrences use the database overlap arbiter." | "Find the earliest certain interval that satisfies the requested duration (up to the 367-day search window, so a multi-month rental slot can be found)." | "Hold up to five candidate slots for a short time (30-900 seconds) while a person chooses, as alternatives in one group: each free one is held, the others are listed with why not. Book one by passing its hold_id to slotlock_create_event, which releases the rest; unbooked holds expire on their own. A hold is yours alone, and you may have 20 live at once." | "How many members of a pool (a vehicle class, a room type) are free across a window, each only where its booking rules let a booking start: the window split where that number changes, each part with how many are free, and coverage certainty. Book one with slotlock_book_pool. 404 pool_not_found when the pool has no member you may book." | "List bounded events intersecting a time window." | "List resources visible to the authenticated tenant principal, with the pool each belongs to (a vehicle class, a room type). With pool, only that pool's members, in the order slotlock_book_pool tries them." | "List up to `limit` certain slots of the requested duration, earliest first across the resources, each one free of busy time and inside the resource's hours, to offer a person a choice. With require_free, only times when at least that many of the resources are free together, each slot naming them in resource_ids: every resource for a booking that needs them all (book it with slotlock_book_together), or any n of them. No slot is listed unless every resource's coverage is certain." | "Read one tenant-scoped event by stable identifier." | "Release one of your holds, and the rest of its group, at once instead of letting them expire. `released` is false when it was already gone." | "Return privacy-minimized busy intervals and explicit coverage certainty." | "Tombstone an event at an expected revision so stale sync cannot resurrect it.";
382
420
  inputSchema: Readonly<Record<string, unknown>>;
383
421
  outputSchema: Readonly<Record<string, unknown>>;
384
422
  annotations: {
385
- title: "Create resource event" | "Delete resource event" | "Find next available interval" | "Get free-busy" | "Get resource event" | "List calendar resources" | "List resource events" | "Update resource event";
423
+ title: "Book from a pool" | "Book resources together" | "Create resource event" | "Delete resource event" | "Find available slots" | "Find next available interval" | "Get free-busy" | "Get pool availability" | "Get resource event" | "Hold candidate slots" | "List calendar resources" | "List resource events" | "Release held slots" | "Update resource event";
386
424
  readOnlyHint: boolean;
387
425
  destructiveHint: boolean;
388
426
  idempotentHint: true;
389
427
  openWorldHint: boolean;
390
428
  };
391
- _meta?: {
429
+ _meta: {
392
430
  ui: {
393
431
  resourceUri: string;
394
432
  visibility: readonly ["model", "app"];
433
+ } | {
434
+ resourceUri: string;
435
+ visibility: readonly ["model"];
436
+ } | {
437
+ visibility: readonly ["model"];
395
438
  };
396
439
  };
397
440
  }[];