slotlock 0.6.2 → 0.7.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 (100) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1544
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +560 -49
  8. package/agent-store-backend.d.ts +39 -7
  9. package/agent-store-backend.js +1439 -104
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +218 -29
  12. package/application-api.d.ts +59 -6
  13. package/application-api.js +842 -39
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +58 -27
  17. package/assistant.js +151 -53
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/booking-rules.d.ts +30 -41
  21. package/booking-rules.js +17 -25
  22. package/change-watch.d.ts +1 -0
  23. package/change-watch.js +1 -1
  24. package/cli.js +3 -4
  25. package/config.d.ts +42 -6
  26. package/config.js +114 -7
  27. package/dashboard-icon.d.ts +1 -0
  28. package/dashboard-icon.js +5 -0
  29. package/dashboard.d.ts +203 -7
  30. package/dashboard.js +1558 -64
  31. package/ddl.d.ts +67 -35
  32. package/ddl.js +2732 -162
  33. package/engine.d.ts +6 -9
  34. package/engine.js +5 -9
  35. package/event-feed.d.ts +53 -35
  36. package/event-feed.js +201 -64
  37. package/event-schema.js +6 -9
  38. package/hooks.d.ts +2 -4
  39. package/hooks.js +2 -4
  40. package/ical-worker-thread.js +3 -4
  41. package/ical-worker.d.ts +8 -10
  42. package/ical-worker.js +7 -9
  43. package/index.d.ts +15 -4
  44. package/index.js +8 -2
  45. package/interop.js +1 -4
  46. package/maintenance.d.ts +24 -10
  47. package/maintenance.js +59 -28
  48. package/mcp-app.d.ts +7 -9
  49. package/mcp-app.js +7 -9
  50. package/mcp-modern.d.ts +14 -16
  51. package/mcp-modern.js +23 -27
  52. package/metrics.d.ts +41 -0
  53. package/metrics.js +22 -1
  54. package/node-server.d.ts +9 -9
  55. package/node-server.js +25 -34
  56. package/openapi.json +14675 -5957
  57. package/otel.d.ts +86 -0
  58. package/otel.js +273 -0
  59. package/overage.d.ts +33 -0
  60. package/overage.js +115 -0
  61. package/package-version.js +2 -4
  62. package/package.json +1 -1
  63. package/partner-billing.d.ts +81 -0
  64. package/partner-billing.js +333 -0
  65. package/partner-names.d.ts +8 -0
  66. package/partner-names.js +10 -0
  67. package/partners.d.ts +147 -0
  68. package/partners.js +375 -0
  69. package/plans.d.ts +92 -0
  70. package/plans.js +115 -0
  71. package/quotas.d.ts +108 -0
  72. package/quotas.js +258 -0
  73. package/rate-limit.d.ts +27 -2
  74. package/rate-limit.js +106 -23
  75. package/resource-profile.d.ts +135 -0
  76. package/resource-profile.js +263 -0
  77. package/rest-api.d.ts +15 -14
  78. package/rest-api.js +79 -15
  79. package/rules.d.ts +6 -7
  80. package/rules.js +30 -42
  81. package/sandbox.d.ts +70 -0
  82. package/sandbox.js +295 -0
  83. package/self-host.d.ts +46 -4
  84. package/self-host.js +990 -41
  85. package/slotlock.manifest.json +1 -1
  86. package/store.d.ts +482 -165
  87. package/store.js +1460 -484
  88. package/sync.d.ts +44 -59
  89. package/sync.js +325 -469
  90. package/teams.d.ts +25 -0
  91. package/teams.js +63 -0
  92. package/timezone.d.ts +25 -33
  93. package/timezone.js +50 -53
  94. package/types.d.ts +130 -57
  95. package/usage.d.ts +106 -0
  96. package/usage.js +238 -0
  97. package/webhooks.d.ts +22 -25
  98. package/webhooks.js +35 -40
  99. package/weekly-hours.d.ts +3 -0
  100. package/weekly-hours.js +1 -1
package/README.md CHANGED
@@ -14,16 +14,20 @@ TypeScript library.
14
14
  - Free/busy says whether it is *certain*: a calendar not yet synced makes time unproven, not free.
15
15
  - Tool names, schemas and errors work on Claude, OpenAI and every MCP host.
16
16
 
17
- ## Install
17
+ ## Get started
18
18
 
19
- ```sh
20
- npm install slotlock postgres
21
- ```
19
+ The documentation is at [slotlock.pylota.io/docs](https://slotlock.pylota.io/docs/). There are
20
+ three ways to run Slotlock:
22
21
 
23
- Node.js 22.12+, PostgreSQL 16 or newer with `btree_gist` (created for you if missing; CI runs 16,
24
- 17 and 18), and `postgres` ^3.4.5 as a peer dependency. TypeScript declarations are included.
22
+ - **[Slotlock Cloud](docs/hosted/README.md)**: nothing to run. Sign in with GitHub at
23
+ `https://app.slotlock.pylota.io/dashboard`, create a key, and connect an agent to
24
+ `https://slotlock-api.pylota.io/mcp`. Free while it is a preview, best effort, with no service
25
+ level agreement.
26
+ - **[Self-host with Docker](#self-host-with-docker)**, or from npm with
27
+ [Self-hosting](https://slotlock.pylota.io/docs/self-host/).
28
+ - **[As a TypeScript library](#quick-start-the-next-free-slot)** inside your own Node.js service.
25
29
 
26
- ## Self-host with Docker
30
+ ### Self-host with Docker
27
31
 
28
32
  From a clone of this repository:
29
33
 
@@ -37,417 +41,26 @@ docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/
37
41
  | --- | --- |
38
42
  | `http://localhost:8080/mcp` | MCP, with `Authorization: Bearer <SLOTLOCK_AUTH_TOKEN>` |
39
43
  | `http://localhost:8080/a2a` | A2A 1.0, same token; card at `/.well-known/agent-card.json` |
40
- | `http://localhost:8080/v1/…` | [REST](#rest-and-openapi): the tools, and the [application API](#application-api) for admin keys; described at `/openapi.json` |
44
+ | `http://localhost:8080/v1/…` | [REST](https://slotlock.pylota.io/docs/api/): the tools, and the [application API](https://slotlock.pylota.io/docs/reference/#application-api) for admin keys; described at `/openapi.json` |
41
45
  | `http://localhost:8080/healthz` | Readiness, including the database |
42
46
  | `http://localhost:8080/metrics` | Prometheus metrics, with `Authorization: Bearer <SLOTLOCK_METRICS_TOKEN>`; off without it |
43
47
 
44
- - Metrics count calls by protocol, operation and outcome (`slotlock_calls_total`, with their
45
- duration in `slotlock_call_duration_seconds`), confirmations, subscriptions, refusals, maintenance
46
- and webhook deliveries. No label names a tenant, key or client. `createSlotlockMetrics` builds
47
- them for a server of your own, from its hooks.
48
- - The server runs as a role that owns nothing and cannot bypass row-level security; a one-off
49
- `migrate` service applies the schema as the owner.
50
- - `SLOTLOCK_AVAILABILITY` sets the default bookable hours. A resource can have its own
51
- (`slotlock hours set`, or the dashboard); with neither, no slot is offered.
52
- `SLOTLOCK_CONFIRM_WRITES=all` (as `.env.example` sets it; the server's default is `none`) makes
53
- every write wait for a person.
54
- - To serve beyond this machine, put a TLS proxy in front and set `SLOTLOCK_PUBLIC_URL`.
55
- - The server limits requests per client address (before any key is looked up), per key, per
56
- tenant and for the whole server, and answers `429 rate_limited` with `Retry-After` and the
57
- `RateLimit` fields. The application API counts an admin key's calls in its tenant's own
58
- `app_read` and `app_write` budgets, apart from the agents'; another key's are its own. A recurring create and every event update (a new title
59
- re-expands a recurring event's series too) takes its write token, then waits for a slot: one at
60
- a time per tenant (`tenant_series_writes`) and per server (`series_writes`); each server process
61
- counts its own. A freed slot rests for as long as the server was busy while the write before
62
- held it (at most a second), so with one slot, writes that each hold it under a second keep the
63
- server busy at most about half the time. It then goes to the waiting tenant with the fewest
64
- writes in flight and, among those, the one whose writes have kept the server busy least lately
65
- (halving every ten seconds), then to the first to ask. So a tenant sending one write at a time
66
- waits for the write running, its rest, and the other tenants with one write in flight whose
67
- writes kept the server less busy: many such tenants, new ones included, can each go ahead of it
68
- once. Tenants keeping more writes waiting never go ahead of it. Four more of a tenant's writes
69
- may wait and the rest get 429. When the server's 64 places are all taken, a write from a tenant
70
- with fewer writes in flight than the tenant with the most takes the place of that tenant's last
71
- write, which gets 503 `server_busy`; otherwise it gets 503 at once. An expansion runs on the
72
- server's main thread, so any request may also wait for the one running. A write that has waited
73
- two seconds gets 429 while its tenant's own write is still running, otherwise 503
74
- `server_busy`. An application API maintenance run waits the same way for slots of its own: one
75
- per tenant, `maintenance_runs` per server. The health check is not counted. One tenant's
76
- requests hold at most `tenant_transactions` (2) of the server's 10 database connections at once
77
- and the rest wait their turn, so a tenant's writes waiting on its own locks leave the others to
78
- other tenants and key lookups; a server with one tenant can set `tenant_transactions=10`. A
79
- request whose transaction waits more than 5 seconds for a lock gets 503 `server_busy`.
80
- With more than one server, `SLOTLOCK_SHARED_LIMITS=on` shares the tenant budgets (`tenant_*`,
81
- `app_*`) through the database, so N servers allow a tenant its rate once, not N times; each server
82
- checks its own buckets first, and goes on with them alone if the database cannot be reached.
83
- `SLOTLOCK_LIMITS` changes the limits; behind a proxy, set `SLOTLOCK_TRUSTED_PROXY_HEADER` to the field it puts the
84
- client's address in (`CF-Connecting-IP` behind Cloudflare) before the server takes traffic, or
85
- every client shares the proxy's budget: the server logs a `client_address_field_ignored` warning
86
- when such a field arrives without the setting. Behind two proxies, such as Cloudflare and then
87
- kamal-proxy, name the first one's field: the server reads the last address of a list field such
88
- as `X-Forwarded-For`, which the second proxy writes as Cloudflare's. A client can send that field
89
- itself, so let nothing reach the server except through the proxy.
90
- - The server also bounds what one tenant may store, whichever key, agent or application writes
91
- it: 10,000 holds, and 100,000 each of bookings, calendar events and coverage rows, counted as
92
- the store's `tenantLimits` count them, besides 10,000 resources. A write past one answers 409
93
- `hold_limit_reached`, `reservation_limit_reached`, `event_limit_reached` or
94
- `coverage_limit_reached`; `SLOTLOCK_LIMITS` changes them (`tenant_holds`,
95
- `tenant_reservations`, `tenant_events`, `tenant_coverage`).
96
-
97
- ```sh
98
- slotlock hours set '[{"rrule":"FREQ=WEEKLY;BYDAY=SA","startMinutes":600,"durationMinutes":240}]' --resource vehicle-42
99
- slotlock hours clear --all # back to SLOTLOCK_AVAILABILITY
100
- ```
101
-
102
- A rule is `rrule`, `startMinutes` and `durationMinutes` here and in `SLOTLOCK_AVAILABILITY`. The
103
- REST application API spells every field in snake_case, so there it is `rrule`, `start_minutes` and
104
- `duration_minutes`. `slotlock --help` lists every setting.
105
-
106
- #### Booking rules
107
-
108
- A resource can also say what a booking on it must satisfy:
109
-
110
- ```sh
111
- slotlock rules set '{"minNoticeMinutes":120,"slotStepMinutes":30,"turnaroundMinutes":30,"enforceHours":true}' --resource vehicle-42
112
- slotlock rules clear --resource vehicle-42
113
- ```
114
-
115
- | Rule | A booking made now |
116
- | --- | --- |
117
- | `minNoticeMinutes` | starts at least this far ahead |
118
- | `maxAdvanceDays` | starts at most this far ahead |
119
- | `slotStepMinutes` | starts on a step from local midnight in the resource's time zone |
120
- | `stepOffsetMinutes` | starts this far past each step (0 to the step less one): with a 30-minute step, 15 starts bookings on :15 and :45 |
121
- | `bufferBeforeMinutes` | keeps this long free before it: its leading buffer, which the database enforces |
122
- | `turnaroundMinutes` | keeps this long free after it: its trailing buffer, which the database enforces |
123
- | `enforceHours` | lies inside the resource's bookable hours |
124
- | `checkInMinutes` | is claimed (`checkInReservation`) this long after its start, or maintenance releases it |
125
- | `requireCertain` | lies in time every calendar sync recording coverage for the resource has read within a day |
126
-
127
- - Holds, reservations, moves and agents' events are checked when they are made, by the database's
128
- clock; a refusal names the rule (`booking_rule_violated` with `rule` in the store,
129
- `booking_rule_min_notice`, `_max_advance`, `_slot_step`, `_outside_hours` or `_uncertain` to an
130
- agent). Under `requireCertain`, a search (the store's and the tools') needs those syncs' coverage
131
- too, read within a day, of the slot and its turnaround, so it offers no slot they leave unproven.
132
- - A series is judged on its first occurrence for notice and advance, and on every occurrence for
133
- step and hours. Imported calendar events are not checked: they happened elsewhere.
134
- - Searches (`slotlock_find_next_available`, `slotlock_find_available_slots`, `findNextAvailableFor`)
135
- offer only slots the rules allow, so an offered slot is one the write accepts.
136
- - Two bookings stay at least the earlier one's trailing buffer plus the later one's leading buffer
137
- apart: each occupies from its start less its leading buffer to its end plus its trailing one, and
138
- the exclusion constraint keeps those apart. Free/busy reports that time as busy.
139
- - The REST application API sets them with `PUT /v1/resources/{resource_id}/booking-rules`, in
140
- snake_case; the store with `setResourceBookingRules`.
141
- - Defaults: rules for every resource of the tenant (`PUT /v1/booking-rules`) and for every member
142
- of a pool (`PUT /v1/pools/{pool}/booking-rules`), read back with `GET /v1/booking-rules`; the
143
- store's `setBookingRuleDefaults` and `getBookingRuleDefaults`. A resource's rules as they apply
144
- (`effective_booking_rules`, `effectiveBookingRules`) are the tenant's defaults, overlaid by its
145
- pool's, overlaid by its own: a rule set at a narrower level wins. `checkInMinutes` is a resource's
146
- own only. The dashboard's resource page shows which rules a resource inherits, and from where.
147
- - With no rule set anywhere, nothing changes: every rule is opt-in.
148
- - A tenant has default rules for at most 1,000 pools (`pool_rules_limit_reached`, 409, for one
149
- more). Merged rules are read leniently: a pool's `stepOffsetMinutes` under a resource's shorter
150
- step applies modulo that step, and a field that is invalid on its own is ignored (and logged),
151
- never read as refusing every booking.
152
-
153
- ### Without Docker
154
-
155
- With Node.js 22.12+ and PostgreSQL 16 or newer, run each command as `npx slotlock` after
156
- `npm install slotlock postgres`, or from a clone, built once, as `node dist/cli.js` (a clone does not
157
- put `slotlock` on your `PATH`). Use two roles, as Compose does, so two URLs: `serve` connects as
158
- `DATABASE_URL`, a role that owns nothing and cannot bypass row-level security, and `migrate`
159
- connects as `SLOTLOCK_MIGRATE_DATABASE_URL`, the schema owner, and grants that role what it needs.
160
- `serve` refuses to start as a superuser or a `BYPASSRLS` role, which row-level security does not
161
- confine, and warns (`serving_as_schema_owner`) when its role owns the schema.
162
-
163
- ```sh
164
- npm ci && npm run build
165
- # Once, as a superuser:
166
- # CREATE ROLE slotlock_app LOGIN PASSWORD '<app password>' NOSUPERUSER NOBYPASSRLS;
167
- export SLOTLOCK_MIGRATE_DATABASE_URL='postgresql://<owner>:<owner password>@localhost:5432/slotlock'
168
- export DATABASE_URL='postgresql://slotlock_app:<app password>@localhost:5432/slotlock'
169
- export SLOTLOCK_AUTH_TOKEN="$(openssl rand -hex 32)"
170
- export SLOTLOCK_CONFIRMATION_SECRET="$(openssl rand -hex 32)"
171
- node dist/cli.js migrate
172
- node dist/cli.js resource add vehicle-42 --timezone Europe/London
173
- node dist/cli.js serve
174
- ```
175
-
176
- The `slotlock …` commands in this README run the same way as `node dist/cli.js …` (with Compose,
177
- `docker compose exec slotlock slotlock …`).
48
+ The server listens on `127.0.0.1:8080`, as a database role that cannot bypass row-level security.
49
+ `.env.example` makes every resource bookable on weekdays 09:00 to 17:00 and every write wait for a
50
+ person. The [Quickstart](https://slotlock.pylota.io/docs/quickstart/) walks through it with Claude
51
+ Code; [Self-hosting](https://slotlock.pylota.io/docs/self-host/) covers the published image,
52
+ configuration and commands, and [Operations](https://slotlock.pylota.io/docs/operations/#production-checklist)
53
+ the production checklist.
178
54
 
179
- ### API keys
180
-
181
- Give each agent its own key instead of sharing the server token:
55
+ ### Quick start: the next free slot
182
56
 
183
57
  ```sh
184
- slotlock key create "Booking agent"
185
- slotlock key create "Availability bot" --scope read --expires-in-days 90
186
- slotlock key create "Booking site" --scope read,write,admin
187
- slotlock key list
188
- slotlock key rotate <id>
189
- slotlock key revoke <id>
190
- ```
191
-
192
- - A key (`slk_…`) is shown once and stored only as a SHA-256. Send it as `Authorization: Bearer`.
193
- - It acts in the tenant it was created for (`SLOTLOCK_TENANT`), so one server serves many tenants.
194
- - `read` covers the seven tools that only look; `write` covers create, update and delete; `admin`
195
- covers the [application API](#application-api), which no agent needs. Default: read and write.
196
- - `rotate` keeps the key's id, scopes and bookings; `revoke` stops it on its next request.
197
- - A tenant may hold 100 active keys. The last six characters are a checksum secret scanners can
198
- verify offline.
199
-
200
- ### Retiring resources and tenants
201
-
202
- ```sh
203
- slotlock resource archive vehicle-42 # no new bookings, holds or events; history kept
204
- slotlock resource restore vehicle-42
205
- slotlock resource delete vehicle-42 # with its history; refused while anything on it is live
206
- slotlock tenant status
207
- slotlock tenant suspend --reason "chargeback review"
208
- slotlock tenant unsuspend
209
- slotlock tenant erase --confirm "$SLOTLOCK_TENANT"
210
- ```
211
-
212
- - The resource commands take a reference or an id, and act in `SLOTLOCK_TENANT`.
213
- - A suspended tenant may read and writes nothing; its keys still authenticate, but it gets no new
214
- key or secret. Only the schema owner suspends or lifts a suspension, so `suspend` and `unsuspend`
215
- connect as `SLOTLOCK_MIGRATE_DATABASE_URL`'s role when it is set.
216
- - `erase` deletes everything the tenant has, its API keys included, at once and for good;
217
- `--confirm` must repeat the tenant. A suspension outlives it.
218
-
219
- ### Dashboard
220
-
221
- The server can also serve a dashboard where people sign in with GitHub and manage their own keys and
222
- resources, so you can give each person API keys without running `slotlock key create` for them:
223
-
224
- 1. On GitHub, open Settings, Developer settings, OAuth apps, New OAuth App, and set the
225
- "Authorization callback URL" to `<SLOTLOCK_PUBLIC_URL>/dashboard/callback`. Then generate a
226
- client secret on the app's page.
227
- 2. Set `SLOTLOCK_GITHUB_CLIENT_ID` and `SLOTLOCK_GITHUB_CLIENT_SECRET` from the app,
228
- `SLOTLOCK_SESSION_SECRET` to 32+ random characters (`openssl rand -hex 32`), and
229
- `SLOTLOCK_DASHBOARD_USERS` to the GitHub user ids that may sign in (`gh api users/<login> --jq .id`),
230
- or to `*` for every GitHub account.
231
- 3. Run `slotlock serve` and open `<SLOTLOCK_PUBLIC_URL>/dashboard`. With Compose, set the four in
232
- `.env` (empty, the default, leaves the dashboard off) and run `docker compose up --detach`.
233
-
234
- - Its pages: an Overview with the coming week's bookings, events and live holds across every
235
- resource (each in its resource's time zone) and the latest changes agents made, named by key;
236
- Resources, each with its own page for bookable hours, booking rules, pool, coming bookings and
237
- activity; Waitlist, to see who waits and remove an entry; API keys, with the MCP and A2A
238
- endpoints; Webhooks, to add, delete and re-enable endpoints and see which are failing; and
239
- Settings.
240
- Everything on them is read from the tenant's own data. `SLOTLOCK_DASHBOARD_NAME` sets what the
241
- dashboard calls the deployment (Slotlock by default).
242
- - Each person gets a tenant of their own, `github:<user id>`: the keys, resources and bookings in it
243
- are theirs alone. Keys made with `slotlock key create` act in `SLOTLOCK_TENANT` instead.
244
- - **For organizations instead of people**, set `SLOTLOCK_DASHBOARD_ORGS` to the GitHub organization
245
- ids the dashboard serves (`gh api orgs/<login> --jq .id`). Each is then one tenant,
246
- `github-org:<id>`, that its members share: keys, resources and hours. A person signs in to the
247
- served organizations they are an active owner or member of (a billing manager is neither),
248
- switching between them when there are several; someone in none of them is refused. Every member
249
- has the same power: each can create, rotate and revoke the organization's keys, and the key list
250
- says who created each one. Membership is read at each sign-in, so a person who leaves keeps
251
- the dashboard until their session ends (12 hours at most), and keys they made work until revoked.
252
- Removing an id from the list signs its sessions out and stops its keys at once. An organization
253
- that restricts OAuth apps must approve the dashboard's app (its Settings, Third-party access), or
254
- GitHub hides the membership. `SLOTLOCK_DASHBOARD_USERS` still says who may sign in at all, and a
255
- key records the person who created it (`github:<user id>`): erasing that person's own tenant
256
- leaves it on keys they made in an organization, which go with the organization's erasure. Only the
257
- first 100 memberships GitHub lists, and the first 20 served organizations by login, are read.
258
- - Sign-in uses `state` and PKCE, and keeps nothing from GitHub but the user id and login (and, with
259
- organizations, the served ones the person belongs to): the GitHub token is used once and dropped.
260
- It asks for no scope, or `read:org` with organizations.
261
- - A session is a signed, HttpOnly cookie that lasts 12 hours. The allowlist is checked on every
262
- request, dashboard and API alike: removing an id and restarting the server signs that person out
263
- and stops every key in their tenant at once.
264
- - Every form carries a token bound to the session and must come from the server's own origin; the
265
- pages run no inline script, under a Content-Security-Policy that allows only their own files.
266
- - Give the dashboard an origin of its own (`https://slotlock.example.com`, not a path beside other
267
- apps): its cookies are host-wide, so any other app on the same origin could read them.
268
- - Finished sign-ins (a callback works once), signed-out sessions (a copied cookie stays out) and sent
269
- forms (a reload does not create or rotate a key twice) are recorded in Postgres, so they hold on
270
- every server that shares the database, behind any load balancer. Each is a SHA-256 digest, kept
271
- until its cookie or form expires (12 hours at most); a form record also carries a digest of the
272
- GitHub user id that sent it. The server role reaches them only through two SECURITY DEFINER
273
- functions, and `serve` refuses to start the dashboard until `migrate` has granted them.
274
- - The database's clock decides when a sign-in or session has expired, so servers whose clocks drift
275
- from it or from each other cannot let a replay or a signed-out session through. Keep clocks in
276
- sync all the same: a server more than 12 hours ahead of the database cannot sign anyone in.
277
- - Each form carries a single-use value bound to its session. A person may have 1,000 forms
278
- recorded at once (12 hours' worth); past that the dashboard answers 429 until the oldest expire.
279
- Signing out always works.
280
- - A person may add 100 resources. The database holds the cap, so adds racing on any number of servers
281
- stop at 100; a resource they already have can still change time zone.
282
- - Each resource uses the server's bookable hours (`SLOTLOCK_AVAILABILITY`) until its owner sets its
283
- own on the hours page: two windows a day, overnight or all day, or closed. One form sets them
284
- for every resource at once.
285
-
286
- #### Assistant: operate Slotlock by chat
287
-
288
- With `SLOTLOCK_ASSISTANT_SECRET` set (32+ random characters, `openssl rand -hex 32`), the dashboard
289
- gains an Assistant page where every member chats with a model their organization connects, instead
290
- of clicking. Each organization brings its own model:
291
-
292
- - **Connect it** in Settings, Assistant (owners only; with personal tenants, each person): the
293
- provider (`Anthropic Messages API`, or `OpenAI-compatible`, which covers OpenAI, OpenRouter,
294
- vLLM, Ollama and LM Studio), the base URL (`https://api.anthropic.com`, or the one ending in
295
- `/v1`: `https://api.openai.com/v1`, `http://localhost:11434/v1`; https, or http only on
296
- `localhost` or `127.0.0.1`), the model name (`your-model`) and the API key, which a local model
297
- may not need. As with webhooks, a URL whose host is, or resolves to, a loopback, private,
298
- link-local, shared (CGNAT), unique-local or metadata address (`169.254.169.254`) is refused, when
299
- it is saved and at each connection (the address the socket connects to is checked, so a name
300
- rebound to one is refused too), and the page says why. For a self-hosted model on the server's
301
- own machine or network (Ollama, vLLM), the operator sets `SLOTLOCK_ASSISTANT_PRIVATE_URLS=on`.
302
- On Cloudflare Workers the literal check applies and names are not resolved: a Worker's fetch
303
- reaches no private network. Changing the provider or the base URL takes the key again (decided in the
304
- database write, so racing saves cannot get past it), so the stored key is only ever sent where it
305
- was entered for, even on an origin that serves several accounts. **Test connection** makes one real request
306
- and checks the model answers with one correct tool call. The organization's messages, and the bookings and resources the assistant
307
- reads, are sent to the provider it configures.
308
- - **The key** is stored sealed with AES-256-GCM under a key derived from `SLOTLOCK_ASSISTANT_SECRET`,
309
- bound to the organization, in `slotlock.assistant_settings` (forced row-level security, erased
310
- with the tenant). The dashboard shows only that it is set and its last four characters. Changing
311
- the secret makes stored keys unreadable: owners enter them again.
312
- - **Reads run, writes wait.** The server runs the tool loop, up to 8 model calls a message, with a
313
- fixed list of the agent tools (the reads: resources, free-busy, next free time, slots, pool
314
- availability, events; the writes: holds, bookings, pool and bundle bookings, changes and
315
- cancellations). A read runs at once through the same operation boundary as an agent's. A write
316
- never runs when the model asks: it appears as a proposed change the member approves (or
317
- dismisses) with a form, and only then runs, once (its idempotency key is the dashboard's), with
318
- the same booking rules, tenant limits and rate limits as an agent, recorded as
319
- `dashboard-assistant:github:<user id>` (shown as "Assistant for @login"). The card leads with
320
- the sentence a confirmation would ask (the resource by name, the times in its zone, and for a
321
- change or cancellation the event's title, resource and time as it stands) and then shows every
322
- argument in full; a change too large to show (16 KB of arguments) is not proposed, and a
323
- conversation has at most 4 waiting. A proposal is only checked: the series-write slot and the
324
- write's rate token are taken when the approved change runs. A message's reads share a budget of
325
- 16 calls and 400 resource-days. API keys, webhooks, booking rules, pools, erasure and
326
- coverage are never offered; a tool the model names outside the list is refused.
327
- - Tool results and booking data are data to the model, never instructions, and the system prompt
328
- says so; the tool list does not change with what the model says. A member may send 10 messages
329
- a minute; each model call has 30 seconds and a message 90. Conversations are kept per member in
330
- `slotlock.assistant_conversations` for 2 hours after the last message, then deleted: by
331
- `serve`'s maintenance, which sweeps every tenant's expired conversations every quarter hour (a
332
- tenant only the dashboard serves included), by `slotlock maintain`, and whenever the tenant's
333
- conversations are read (so on Workers too). They are bounded in UTF-8 bytes; **New conversation** forgets one. On Workers, the
334
- assistant's agent calls are capped at 24 a request and by `tenant_read` and `tenant_write` of
335
- `SLOTLOCK_LIMITS` in each isolate. A provider's failure is shown as a plain message, never with
336
- the key or the provider's answer.
337
- - It works as plain forms: no script is needed.
338
-
339
- ### Dashboard on Cloudflare Workers
340
-
341
- The same dashboard can run as a Cloudflare Worker at an origin of its own (`workers/dashboard`),
342
- while agents keep using the server. It shows the server's MCP and A2A URLs (`SLOTLOCK_SERVICE_URL`),
343
- and reaches the server's database through Hyperdrive, as the role the server serves as.
344
-
345
- `workers/dashboard/wrangler.jsonc` is an example: copy it into your own deployment and replace what
346
- it marks as yours. This repository's Dashboard workflow only checks that the Worker bundles. Deploy
347
- from your own pipeline with your own Cloudflare token: a token that can deploy Workers can replace
348
- this one and read its session secret, which signs in as any tenant.
349
-
350
- 1. Create a Hyperdrive configuration for that role, with caching off: the dashboard reads back a
351
- sign-in or a form it has just recorded, and a cached sign-out check would keep a copied session
352
- working. Check it before each deploy (`wrangler hyperdrive get <id>`). For a database on a private
353
- network, connect it through Workers VPC, on the Cloudflare Tunnel that reaches it. Point the VPC
354
- service at a listener that serves only Slotlock's database, such as a PgBouncer with TLS and only
355
- that database and role, rather than at the whole Postgres:
356
- ```sh
357
- cd workers/dashboard && npm ci
358
- npx wrangler vpc service create slotlock-postgres --type tcp --tunnel-id <tunnel id> \
359
- --ipv4 <listener host> --tcp-port <listener port> --app-protocol postgresql
360
- read -rs SLOTLOCK_DB_PASSWORD # not on the command line, so not in your shell history
361
- npx wrangler hyperdrive create slotlock-dashboard --service-id <vpc service id> \
362
- --database <database> --user <server role> --password "$SLOTLOCK_DB_PASSWORD" --caching-disabled
363
- ```
364
- 2. In your copy of the configuration, set:
365
- - the configuration's id;
366
- - your hostname (`routes`);
367
- - `SLOTLOCK_PUBLIC_URL`, `SLOTLOCK_SERVICE_URL` and `SLOTLOCK_DASHBOARD_USERS` (`vars`);
368
- - the three rate limiting bindings, each with a `namespace_id` unique in your account.
369
- 3. Create the GitHub OAuth app as above, with the callback URL
370
- `<SLOTLOCK_PUBLIC_URL>/dashboard/callback` on the Worker's origin.
371
- 4. Store the secrets on the Worker: `npx wrangler secret put` for `SLOTLOCK_GITHUB_CLIENT_ID`,
372
- `SLOTLOCK_GITHUB_CLIENT_SECRET` and `SLOTLOCK_SESSION_SECRET`, and `SLOTLOCK_ASSISTANT_SECRET`
373
- for the assistant.
374
- 5. Deploy: `npm ci --omit=dev` at the repository root (the Worker bundles Slotlock's sources), then
375
- `npx wrangler deploy --config <your configuration>` in `workers/dashboard`.
376
-
377
- - Each request opens its own database client, at most five connections, and closes it when the
378
- response is sent; Hyperdrive keeps the pool.
379
- - Anyone may start a sign-in, and each callback costs a database write and a request to GitHub
380
- with the app's client secret. An isolate runs at most eight at once, but Workers run many
381
- isolates, so three rate limiting bindings bound them all:
382
- - `SLOTLOCK_CLIENT_RATE_LIMIT` counts every dashboard request per client address (an IPv6
383
- client's /64, as `slotlock serve` counts it);
384
- - `SLOTLOCK_SIGN_IN_RATE_LIMIT` counts sign-ins and callbacks per client network (an IPv6
385
- client's /48, a site's allocation, as `slotlock serve` counts its sign-ins), so a site rotating
386
- through its /64s cannot use up the ceiling;
387
- - `SLOTLOCK_SIGN_IN_CEILING_RATE_LIMIT` counts them from every client together, set far above one
388
- client's limit so a few clients cannot use it up for everyone.
389
-
390
- Their counts are per Cloudflare location. Without either of the first two the Worker answers 503.
391
- Without the ceiling, as in a configuration from before it, `SLOTLOCK_SIGN_IN_RATE_LIMIT` counts
392
- every client together, and the Worker logs a warning: add the ceiling before lowering that
393
- binding's limit to one client's. The example configuration also removes query strings (a
394
- callback's OAuth code) from the URLs Workers Logs keep.
395
- - With organizations, set `SLOTLOCK_DASHBOARD_ORGS` on both the Worker and the server: the server
396
- takes an organization's keys only while its own list names it, whether or not it serves the
397
- dashboard.
398
- - The server checks a dashboard tenant against `SLOTLOCK_DASHBOARD_USERS` only when it serves the
399
- dashboard itself. With the dashboard on Workers, removing a person from the Worker's list signs
400
- them out of the dashboard; to stop their keys, revoke them or erase the tenant.
401
- - A missing or weak secret makes the Worker answer 503 and log which variable, never its value.
402
-
403
- ## Connect an MCP client
404
-
405
- Point any Streamable HTTP client at `http://localhost:8080/mcp` with the token.
406
-
407
- **Claude Code:**
408
-
409
- ```sh
410
- claude mcp add --transport http slotlock http://localhost:8080/mcp \
411
- --header "Authorization: Bearer $SLOTLOCK_AUTH_TOKEN"
412
- ```
413
-
414
- **Cursor** (`~/.cursor/mcp.json`):
415
-
416
- ```json
417
- {
418
- "mcpServers": {
419
- "slotlock": {
420
- "url": "http://localhost:8080/mcp",
421
- "headers": { "Authorization": "Bearer ${env:SLOTLOCK_AUTH_TOKEN}" }
422
- }
423
- }
424
- }
425
- ```
426
-
427
- **VS Code** (`.vscode/mcp.json`; it asks for the token once):
428
-
429
- ```json
430
- {
431
- "inputs": [
432
- { "type": "promptString", "id": "slotlock-token", "description": "Slotlock token", "password": true }
433
- ],
434
- "servers": {
435
- "slotlock": {
436
- "type": "http",
437
- "url": "http://localhost:8080/mcp",
438
- "headers": { "Authorization": "Bearer ${input:slotlock-token}" }
439
- }
440
- }
441
- }
58
+ npm install slotlock postgres
442
59
  ```
443
60
 
444
- With `SLOTLOCK_CONFIRM_WRITES=all`, writes need a client that can show a confirmation (MCP
445
- 2026-07-28 with form elicitation); other clients get `confirmation_required`. Set it to `none` to let
446
- agents book on their own.
447
-
448
- ## Quick start: the next free slot
449
-
450
- The availability maths needs no database:
61
+ Node.js 22.12+, PostgreSQL 16 or newer with `btree_gist` (created for you if missing), and
62
+ `postgres` ^3.4.5 as a peer dependency. TypeScript declarations are included. The availability
63
+ maths needs no database:
451
64
 
452
65
  <!-- example: examples/availability.ts#next-slot -->
453
66
  ```ts
@@ -472,1149 +85,42 @@ export const slot = findNextAvailable({
472
85
  // slot: 2026-09-14 10:00–12:00 UTC, the first two free hours after the busy one
473
86
  ```
474
87
 
475
- A rule `expandRules` cannot evaluate adds no availability; it never invents a slot. A search window
476
- may be 367 days at most (`invalid_window`), here and in `findNextAvailableFor`.
477
-
478
- ## Set up PostgreSQL
479
-
480
- | Role | Owns | Used for |
481
- | --- | --- | --- |
482
- | Deployment | the `slotlock` schema | `applySchema`, `applyTenantRls`, `grantApplicationRole`, once per release |
483
- | Application | nothing (`NOSUPERUSER NOBYPASSRLS`) | all traffic, confined to one tenant by forced row-level security |
484
-
485
- Create the application role once (`CREATE ROLE slotlock_app LOGIN PASSWORD '…' NOSUPERUSER
486
- NOBYPASSRLS;`), then deploy as the owner:
487
-
488
- <!-- example: examples/deploy.ts#deploy -->
489
- ```ts
490
- import { createSlotlockStore } from 'slotlock';
491
- import postgres from 'postgres';
492
-
493
- export async function deploySlotlock(deployUrl: string, applicationRole: string): Promise<void> {
494
- const sql = postgres(deployUrl, { max: 1, onnotice: () => {} });
495
- try {
496
- const store = createSlotlockStore(sql);
497
- await store.applySchema();
498
- await store.applyTenantRls();
499
- await store.grantApplicationRole(applicationRole);
500
- } finally {
501
- await sql.end();
502
- }
503
- }
504
- ```
505
-
506
- - `applySchema` is idempotent and safe to run from several instances at once.
507
- - `grantApplicationRole` grants schema usage and DML on Slotlock's tables, and refuses
508
- (`unsafe_application_role`) a role that could get around row-level security. Run it after every
509
- `applySchema`. [SECURITY.md](./SECURITY.md#security-model) lists what it checks.
510
- - `applySchema` refuses a `slotlock` schema another role controls (`unsafe_slotlock_schema`).
511
- - Already have a tenant setting such as `app.tenant_id`? Pass `{ tenantContextSetting:
512
- 'app.tenant_id' }` to `createSlotlockStore`.
513
-
514
- ## Book and read as the application
515
-
516
- <!-- example: examples/store.ts#connect -->
517
- ```ts
518
- import { type SlotlockStore, createSlotlockStore } from 'slotlock';
519
- import postgres from 'postgres';
520
-
521
- export function openSlotlock(applicationUrl: string) {
522
- const sql = postgres(applicationUrl, { max: 10 });
523
- return { sql, store: createSlotlockStore(sql) };
524
- }
525
- ```
526
-
527
- <!-- example: examples/store.ts#book -->
528
- ```ts
529
- export async function bookHandover(store: SlotlockStore, tenantRef: string) {
530
- return store.withTenant(tenantRef, async (tenant) => {
531
- const vehicle = await tenant.createResource({
532
- tenantRef,
533
- externalRef: 'vehicle-42',
534
- timezone: 'Europe/London',
535
- });
536
- const handover = await tenant.putCalendarEvent({
537
- tenantRef,
538
- externalRef: 'handover-BK-42',
539
- idempotencyKey: 'booking-BK-42-create',
540
- expectedRevision: 0,
541
- resourceId: vehicle.id,
542
- start: new Date('2027-03-29T09:00:00Z'),
543
- end: new Date('2027-03-29T10:00:00Z'),
544
- timezone: 'Europe/London',
545
- summary: 'Vehicle handover',
546
- organizer: { email: 'fleet@example.com', name: 'Fleet Desk' },
547
- attendees: [{ email: 'renter@example.com', participationStatus: 'needs_action', rsvp: true }],
548
- reminders: [{ action: 'display', minutesBeforeStart: 30 }],
549
- });
550
- if (!handover.ok) throw new Error(`handover not booked: ${handover.code}`);
551
-
552
- const freeBusy = await tenant.getFreeBusy({
553
- tenantRef,
554
- resourceId: vehicle.id,
555
- window: { start: new Date('2027-03-29T00:00:00Z'), end: new Date('2027-03-30T00:00:00Z') },
556
- });
557
- return { vehicle, handover, freeBusy };
558
- });
559
- }
560
- ```
561
-
562
- Run every store method inside `withTenant`. A call outside it is not refused: it runs with no tenant
563
- context, so as the application role forced row-level security shows it no rows.
564
-
565
- `withTenant` validates the tenant, sets the context transaction-locally on the one connection every
566
- callback call uses, supports nested savepoints, and restores the previous context. Do not issue a
567
- standalone `set_config(..., true)` through a pool: its transaction ends before the next call.
568
- `withTenant(tenantRef, callback, { isolation: 'read committed' })` runs the callback in a READ
569
- COMMITTED transaction whatever the role's default, and refuses inside a caller's transaction of
570
- another level. A store sharing one pool among tenants can bound each tenant's share:
571
- `createSlotlockStore(sql, { maxTenantTransactions, tenantLockTimeoutMs })` runs at most that many
572
- of a tenant's `withTenant` transactions at once (the rest wait in the process, and leave the line
573
- when `withTenant`'s `signal` aborts), and refuses one that waits longer than that for a lock with
574
- `lock_timeout`, safe to retry. `slotlock serve` sets `tenant_transactions` (2) and 5,000.
575
-
576
- `createResource({ ..., maxTenantResources })` refuses a new resource (`resource_limit_reached`) once
577
- the tenant has that many (1-100,000); an existing `externalRef` is still updated. Capped creates for
578
- one tenant take a database lock and count under READ COMMITTED, so racing ones stop at the cap on
579
- any number of servers; inside a REPEATABLE READ or SERIALIZABLE transaction they refuse
580
- (`invalid_transaction_isolation`). Uncapped creates take no lock.
581
-
582
- `setResourceAvailability({ tenantRef, id, rules })` gives a resource its own bookable hours (`null`
583
- uses the default, `[]` closes it); `setTenantAvailability({ tenantRef, rules })` sets every resource
584
- of a tenant. `createSlotlockStoreAgentBackend` answers from a resource's own hours and calls
585
- `availabilityRules` only for resources without them.
586
-
587
- Waitlists: `joinWaitlist({ tenantRef, resourceId | pool, start, end, holderRef, offerTtlMs })`
588
- waits for a window that is taken now. When it comes free (a cancel, a released or expired hold, a
589
- no-show), `offerWaitlist` gives it to the entries in the order they joined: the first whose window
590
- is free, on its resource or the first free member of its pool, gets a hold for its holder, and the
591
- feed says so (`waitlist.offered`, with `hold_id`). Confirm the hold to book it; let it go and the next
592
- is offered. Cancels, releases and moves, whether an agent (MCP, A2A) or the application API makes
593
- them, offer at once; maintenance offers what lapses on its own (an expired hold, a no-show) and
594
- expires entries whose window has started (`waitlist.expired`). With `checkInMinutes` in a
595
- resource's booking rules, maintenance releases a booking made since the rule was set that nobody
596
- checked in that long after its start, or after it was made for a walk-in (`reservation.no_show`),
597
- and the waitlist is offered its time. Check-in opens an hour before the start; moving a booking to
598
- another start clears it. Leaving the waitlist lets an offer not yet confirmed go with it.
599
-
600
- Calendar tools speak the standards: `emitVAvailability({ rules, timezone, uid })` writes rules as an
601
- iCalendar VAVAILABILITY (RFC 7953) and `parseVAvailability(text)` reads one back as
602
- `{ timezone, rules }`; `toJSCalendarEvent(event)` answers a calendar event as a JSCalendar Event
603
- (RFC 8984). The API serves them at `/v1/resources/{resource_id}/availability/ical` and
604
- `/v1/tenant/events/{event_id}/jscalendar`.
605
-
606
- Writing rules:
607
-
608
- - Create with `expectedRevision: 0`; update with the revision you last read and a new idempotency
609
- key. Replaying the same command and key returns the original result (`idempotent: true`); reusing
610
- a key with a different payload returns `idempotency_conflict`.
611
- - A conflicting opaque occurrence returns `{ ok: false, code: 'overlap' }`. Transparent events
612
- never block.
613
- - Cancelling leaves a tombstone, so a delayed create cannot resurrect the event; use a new external
614
- reference for a genuinely new event.
615
- - `listCalendarEvents` and `listResources` return at most 1,000 rows and page with keyset cursors.
616
-
617
- ### Holds, external bookings and the feed
618
-
619
- <!-- example: examples/store.ts#holds -->
620
- ```ts
621
- export async function holdBookAndFollow(store: SlotlockStore, tenantRef: string, resourceId: string) {
622
- // Inside withTenant: outside it the application role's row-level security shows no rows.
623
- const booked = await store.withTenant(tenantRef, async (tenant) => {
624
- // Hold a window for ten minutes while the customer pays, then turn it into a booking.
625
- const held = await tenant.acquireHold({
626
- tenantRef,
627
- resourceId,
628
- start: new Date('2027-04-05T09:00:00Z'),
629
- end: new Date('2027-04-05T10:00:00Z'),
630
- ttlMs: 10 * 60_000,
631
- });
632
- if (!held.ok) throw new Error(`not held: ${held.code}`);
633
- const confirmed = await tenant.confirmHold(held.hold.id, { tenantRef });
634
- if (!confirmed.ok) throw new Error(`hold not confirmed: ${confirmed.code}`);
635
-
636
- // A booking your own system already has, by its reference there: revision 1 creates it.
637
- const imported = await tenant.createExternalReservation({
638
- tenantRef,
639
- externalRef: 'BK-43',
640
- resourceId,
641
- start: new Date('2027-04-05T11:00:00Z'),
642
- end: new Date('2027-04-05T12:00:00Z'),
643
- bufferAfterMs: 15 * 60_000,
644
- revision: 1,
645
- });
646
- if (!imported.ok) throw new Error(`BK-43 not booked: ${imported.code}`);
647
- return { booking: confirmed.reservation, imported: imported.reservation };
648
- });
649
-
650
- // What changed, oldest first, once committed: so in a withTenant of its own. Poll again with
651
- // `after: feed.next`.
652
- const feed = await store.withTenant(tenantRef, (tenant) =>
653
- tenant.listChanges({ tenantRef, limit: 100 }),
654
- );
655
- if (!feed.ok) throw new Error(`feed not read: ${feed.code}`);
656
- return { ...booked, feed };
657
- }
658
- ```
659
-
660
- ### Recurring events
661
-
662
- <!-- example: examples/store.ts#recurring -->
663
- ```ts
664
- export async function scheduleWeeklyInspection(
665
- store: SlotlockStore,
666
- tenantRef: string,
667
- resourceId: string,
668
- ) {
669
- return store.withTenant(tenantRef, (tenant) =>
670
- tenant.putCalendarEvent({
671
- tenantRef,
672
- externalRef: 'inspection-vehicle-42',
673
- idempotencyKey: 'inspection-vehicle-42-create',
674
- expectedRevision: 0,
675
- resourceId,
676
- start: new Date('2027-03-30T07:00:00Z'),
677
- end: new Date('2027-03-30T08:00:00Z'),
678
- timezone: 'Europe/London',
679
- summary: 'Weekly inspection',
680
- recurrence: { rrule: 'FREQ=WEEKLY;COUNT=8' },
681
- materializationWindow: {
682
- start: new Date('2027-03-30T00:00:00Z'),
683
- end: new Date('2027-05-30T00:00:00Z'),
684
- },
685
- }),
686
- );
687
- }
688
- ```
689
-
690
- Occurrences are stored for a window of at most 367 days and 2,000 occurrences; maintenance rolls
691
- the window forward, never moving its end back. Outside it, before or after, free/busy reports the
692
- time as unproven.
693
-
694
- A rule is expanded within a budget of steps, a count of work that reads no clock, so the same input
695
- is accepted or refused however loaded the machine: 500,000 for a feed (the whole read, from its
696
- text) or for a stored series as maintenance rolls it, about half a second, and 50,000 for one event
697
- as it is written, refused within a tenth of a second whatever its rule or time zone (on a laptop; a
698
- 4-vCPU Linux server takes up to twice as long). A step is about a microsecond: a step of ical.js's
699
- search costs three, a weekday compared one, five days walked one, a year scanned three (and one for
700
- each eight days it lists), and two weekdays of a year listed one. Each occurrence passed costs one
701
- more, each conversion of a time in an IANA zone half (one and a half in a zone only the feed
702
- defines), and each instant Intl is asked for to read a zone's offsets five, the first time an
703
- expansion reads that day of the zone, whatever the process has read before. A feed's own time zones
704
- (VTIMEZONE) count against the same budget, each change listed when one is expanded costing four
705
- tenths: one written the way Outlook writes them, from 1601, costs about 6,000. A feed may carry 100
706
- time zones and name 100 it does not define, 1,000 observances and 2,000 RDATE values in the zones it
707
- carries, and 20,000 RDATE and EXDATE values in its events, and an event 8 RRULEs, each further one
708
- costing a step at every occurrence its expansion passes; a property's name and parameters may take
709
- 8 KB and list 100. A MINUTELY to WEEKLY step, a `DURATION`, or an event's length may be a century at
710
- most. A calendar that needs more, such as a rule that never occurs
711
- (`FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30`), is refused: `invalid_event` on a write, `invalid_icalendar`
712
- from a feed.
713
-
714
- A feed's rules are expanded from their start, and each series may pass 4,000 occurrences on the way
715
- (a daily one, about 11 years) whatever the feed's other series take. A feed series with one DAILY or
716
- WEEKLY rule and no COUNT, RDATE or `RANGE=THISANDFUTURE` override is searched from just before the
717
- window instead, so a daily standup since 2014 reads like one that began this year. A stored series
718
- keeps where its last expansion stopped, and the next one resumes there, so a daily series keeps
719
- rolling for as long as it runs: its budget goes on the window, not on the years before it. A write
720
- that changes a series searches from its start, and resumes from where it stopped only if that search
721
- is over the budget. A place serves only the rule, start and time zone it was kept for, read by the
722
- same version of ical.js, and never lies past an occurrence that could still reach the window (one
723
- within the event's length and a day of it, or one an exception moves into it). Otherwise the series
724
- is searched from its start, within the same budget. During maintenance, a stored series that
725
- outgrows the budget, that an earlier release stored and this one refuses, or whose roll fails for
726
- any other reason (a row no release could have written, such as a rule ical.js cannot read), is
727
- counted as `refused` (its ids in `refusedEventIds`), keeps its old coverage and waits an hour behind
728
- the others. A pass whose roll fails still prunes, then reports the failure.
729
- `storedCalendarEventProblem(event)` says why this
730
- release would refuse a stored event (a rule shape it no longer expands, `COUNT` with a yearly day
731
- some years lack, or an exception that names no occurrence), or `null`; run it over your stored
732
- events before upgrading.
733
-
734
- A stored rule is daily or less frequent and follows RFC 5545's grammar: `COUNT=0`, an unknown or
735
- repeated part, or `COUNT` with `UNTIL` is refused. Shapes ical.js 2.2.1 expands differently from
736
- RFC 5545 are refused everywhere, a feed included, and `recurrenceRuleProblem(rule)` says why:
737
- `BYWEEKNO`; a two-digit `BYDAY` ordinal; `BYSETPOS` other than with `FREQ=MONTHLY` and `BYDAY`
738
- alone; `BYMONTH` with `FREQ=MONTHLY`; a negative `BYMONTHDAY` other than with `FREQ=MONTHLY` and
739
- no `BYDAY`; `FREQ=YEARLY` with `BYMONTHDAY` but no `BYMONTH`, with a day none of its months has,
740
- with `COUNT` or `BYDAY` and a day one of its months can lack, with `BYYEARDAY` and `BYMONTH` or
741
- `BYMONTHDAY`, or with a numbered `BYDAY` and `BYMONTHDAY`; `BYHOUR` with `FREQ=HOURLY`,
742
- `BYMINUTE` or `BYSECOND` with `FREQ=MINUTELY`, and any of them with `FREQ=MONTHLY` or `YEARLY`;
743
- and `FREQ=SECONDLY`. A yearly day some years lack (29 February, or a 31st with April) occurs only
744
- in the years that have it, as RFC 5545 says, so its series may not add an RDATE or a second RRULE.
745
- A DATE `UNTIL` on a timed series includes its whole day on the series' clock. An event's time zone
746
- is an IANA name, stored in its canonical spelling (`europe/london` becomes `Europe/London`); a UTC
747
- offset such as `+05:00` is refused.
748
-
749
- A recurrence exception names the occurrence it cancels or moves by that occurrence's instant, as
750
- `expandCalendarEventOccurrences` reports it, and is exported as a UTC `RECURRENCE-ID`; one that
751
- names no occurrence is refused, so a mistyped instant is an error rather than a cancellation that
752
- frees nothing. A series' occurrences never overlap each other: one that reaches past the next one's
753
- start (a day-long occurrence the night the clocks go forward) ends there, and the next ends at the
754
- later of the two ends, so the series holds the same time as separate reservations.
755
-
756
- ### External calendars
757
-
758
- Provider adapters (Google, Microsoft, CalDAV) stay in your application, with their credentials.
759
- Slotlock gives them neutral boundaries:
760
-
761
- - `normalizeExternalCalendarChange` for webhook changes; `parseICalendarChanges` and `emitICalendar`
762
- for bounded iCalendar in and out; `emitITipCalendar` for invitations.
763
- - `parseICalendarFeed` reads a feed series by series: a series it cannot read (a refused rule
764
- shape, an end the clocks move onto its start, DTEND with DURATION, more than 4,000 occurrences
765
- from its start, or more work than the feed's budget has left) is named in `refused` by UID, with
766
- `limit: true` for the last two, and the rest is read. Treat a refused series' time as busy, or the
767
- feed's coverage as incomplete. `parseICalendarChanges` refuses the whole feed instead.
768
- - All-day events become whole local days in the calendar's time zone; a feed without one is refused.
769
- - A TZID Intl knows is read as that IANA zone, whether or not the feed defines it (a VTIMEZONE's
770
- own rules for such a name are not used); one only the feed defines, such as a Windows name, is
771
- read from its VTIMEZONE. A floating time is read in the calendar's zone. So an UNTIL, EXDATE,
772
- RDATE or RECURRENCE-ID written in UTC or another zone names the occurrence it means, a floating
773
- UNTIL is read on the series' own clock, and a `RANGE=THISANDFUTURE` exception moves every later
774
- occurrence by its own shift.
775
- - An event with no length (a DATE-TIME DTSTART without DTEND or DURATION) occupies no time and is
776
- left out. A PERIOD RDATE ends where its period does. A document whose root is not a VCALENDAR, or
777
- an event that ends before it starts, is refused as `invalid_icalendar`.
778
- - A series' DTSTART is its first occurrence, with or without an RRULE; every RRULE is expanded; an
779
- instant two of them, or an RRULE and an RDATE, name is one occurrence; every EXDATE excludes the
780
- occurrence it names (RFC 5545 §3.8.5). A DATE EXDATE of a timed series names the occurrence at the
781
- series' start time that day.
782
- - An occurrence of a series occupies both readings of it when the clocks change inside it: its
783
- exact length from its start (RFC 5545 §3.8.5.3) and its written end on the wall clock (the later
784
- instant of a repeated hour). 00:00-06:00 holds six hours the night the clocks go forward and
785
- seven the night they go back. A start in a skipped hour moves forward by the gap; one in a
786
- repeated hour is its first instant. One-off events, overrides and PERIOD RDATEs occupy exactly
787
- the times they give. A series' occurrences never overlap each other, as for stored series. SPEC.md,
788
- "Daylight-saving time", lists the cases.
789
- - An override without DTEND or DURATION lasts its series' length. One whose RECURRENCE-ID names no
790
- occurrence, or whose occurrence the series also EXDATEs, occupies its own time, as the provider
791
- shows it. The instances of a CalDAV-expanded series are each read, the first with or without a
792
- RECURRENCE-ID. An event with both DTEND and DURATION is refused.
793
- - `parseTrustedICalendarEvents` reads times as `parseICalendarChanges` does, so a series stored from
794
- it occupies what the feed reports; an all-day series, stored from local midnight to local
795
- midnight, may also hold the hour after a midnight the clocks go forward on, never less. It drops
796
- an EXDATE that names no occurrence, and refuses what a stored series cannot hold: a zone only the
797
- feed defines (a Windows name such as `GMT Standard Time`; map it to an IANA zone first), a DTSTART
798
- or DTEND the clocks skip (an all-day series' first midnight included), a `DURATION` that counts
799
- days or weeks outside UTC, an instance whose transparency differs from its series', or a moved
800
- instance that names no occurrence.
801
- - `emitICalendar` and `emitITipCalendar` write a VTIMEZONE for the zone their times name (RFC 5545
802
- §3.6.5), from the IANA rules: every change over the years the event covers, and, where a zone
803
- changes on the same numbered or last weekday of a month every year, yearly rules that carry a
804
- series on past them. A zone that does not, such as Casablanca, Gaza, Jerusalem or Santiago, is
805
- exact over those years only.
806
- - `recordCalendarCoverage` marks how much of a feed is read. Pass the sources a resource depends on as
807
- `requiredSources`, and any coverage short of `complete` means the time is not proven free.
808
-
809
- Read a feed someone else controls with `createICalendarWorkerPool`, which runs the same parsers on
810
- worker threads. However costly the feed, the thread that serves requests never runs ical.js on it: a
811
- worker past its deadline (3 seconds by default) or its heap (128 MB) is stopped, and the feed is
812
- refused with `CalendarExpansionLimitError`. The pool offers `parseICalendarFeed` too. When every
813
- worker is busy and the queue is full, the pool answers `ICalendarWorkerBusyError` at once. A worker
814
- starts in about 40 ms and is reused; idle workers do not keep the process alive. The pool runs from
815
- the built package (the worker is `ical-worker-thread.js`), not from Slotlock's TypeScript sources.
816
-
817
- <!-- example: examples/feeds.ts#feeds -->
818
- ```ts
819
- import {
820
- CalendarExpansionLimitError,
821
- ICalendarWorkerBusyError,
822
- createICalendarWorkerPool,
823
- } from 'slotlock';
824
-
825
- // One pool for the process. Each feed is read on a worker thread; one that runs past its deadline
826
- // or its heap is stopped, and the feed refused, while this thread carries on.
827
- const feeds = createICalendarWorkerPool({ size: 2, deadlineMs: 3_000 });
828
-
829
- export async function readFeed(text: string, window: { start: Date; end: Date }) {
830
- try {
831
- return {
832
- ok: true as const,
833
- changes: await feeds.parseICalendarChanges(text, undefined, window),
834
- };
835
- } catch (error) {
836
- // Every worker is busy and the queue is full: read this feed on the next sync.
837
- if (error instanceof ICalendarWorkerBusyError) return { ok: false as const, retry: true };
838
- // The feed takes more than Slotlock will spend reading one: tell its owner, do not retry.
839
- if (error instanceof CalendarExpansionLimitError) return { ok: false as const, retry: false };
840
- throw error;
841
- }
842
- }
843
-
844
- export function closeFeeds() {
845
- return feeds.close();
846
- }
847
- ```
848
-
849
- ## Expose Slotlock to agents
850
-
851
- One HTTP handler serves the tools over MCP and A2A. The tenant comes from your `authenticate`, never
852
- from a tool argument.
853
-
854
- <!-- example: examples/agent-server.ts#server -->
855
- ```ts
856
- import {
857
- type SlotlockAgentOperation,
858
- type SlotlockAgentPrincipal,
859
- type SlotlockStore,
860
- createSlotlockAgentServer,
861
- createSlotlockStoreAgentBackend,
862
- createSlotlockStoreChangeSource,
863
- } from 'slotlock';
864
- import { createSlotlockNodeServer } from 'slotlock/node-server';
865
- import type { Sql } from 'postgres';
866
-
867
- const ALLOWED_OPERATIONS: ReadonlySet<SlotlockAgentOperation> = new Set([
868
- 'slotlock_list_resources',
869
- 'slotlock_get_free_busy',
870
- 'slotlock_find_next_available',
871
- 'slotlock_create_event',
872
- 'slotlock_get_event',
873
- 'slotlock_list_events',
874
- 'slotlock_update_event',
875
- ]);
876
-
877
- export interface CalendarServerConfig {
878
- store: SlotlockStore;
879
- /** The store's client: live updates read the change feed's head with it. */
880
- sql: Sql;
881
- publicBaseUrl: string;
882
- port: number;
883
- verifyToken(token: string): Promise<SlotlockAgentPrincipal | null>;
884
- allowInsecureLocalhost?: boolean;
885
- confirmationSecret?: string;
886
- }
887
-
888
- export async function startCalendarServer(config: CalendarServerConfig) {
889
- const server = createSlotlockAgentServer({
890
- publicBaseUrl: config.publicBaseUrl,
891
- allowInsecureLocalhost: config.allowInsecureLocalhost === true,
892
- ...(config.confirmationSecret
893
- ? {
894
- confirmation: {
895
- operations: ['slotlock_create_event', 'slotlock_update_event'] as const,
896
- secrets: [config.confirmationSecret],
897
- },
898
- }
899
- : {}),
900
- resourceWindowDays: 90,
901
- // Live updates follow each tenant's change feed: a booking is announced as it commits.
902
- subscriptions: { changes: createSlotlockStoreChangeSource(config.store, config.sql) },
903
- backend: createSlotlockStoreAgentBackend(config.store, {
904
- availabilityRules: async () => [
905
- { rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', startMinutes: 9 * 60, durationMinutes: 480 },
906
- ],
907
- }),
908
- authenticate: async (request) => {
909
- const token = /^Bearer (.+)$/.exec(request.headers.get('authorization') ?? '')?.[1];
910
- return token ? config.verifyToken(token) : null;
911
- },
912
- authorize: async ({ operation }) => ALLOWED_OPERATIONS.has(operation),
913
- health: async () => ({ ready: true, checks: ['database'] }),
914
- });
915
-
916
- const listener = createSlotlockNodeServer(server, {
917
- requestOrigin: new URL(config.publicBaseUrl).origin,
918
- });
919
- await listener.listen({ host: '127.0.0.1', port: config.port });
920
- return listener;
921
- }
922
- ```
923
-
924
- - `authorize` runs on every call with the current tool name, even when a client used an old alias.
925
- Prefer an allow-list, so a tool added in a later release stays off until you allow it.
926
- - `availabilityRules` returns the default bookable hours, for resources without their own. No
927
- rules means never bookable.
928
- - `confirmation` makes the listed writes wait for a person ([Confirm before writing](#confirm-before-writing)).
929
- - `publicBaseUrl` must be HTTPS (`allowInsecureLocalhost` permits `http://localhost` for
930
- development). Run the Node listener behind a TLS proxy, or mount `server.fetch` in any Fetch
931
- runtime.
932
- - The server sends no CORS headers: call it from your backend, not a web page.
933
-
934
- ### Endpoints
935
-
936
- | Method and path | What |
937
- | --- | --- |
938
- | `POST {base}/mcp` | MCP over Streamable HTTP, stateless |
939
- | `POST {base}/a2a` | A2A 1.0 JSON-RPC |
940
- | `GET {base}/.well-known/agent-card.json` | A2A agent card, also at the origin root |
941
- | `GET {base}/manifest.json` | Protocol versions, endpoints and every tool's JSON Schemas |
942
- | `GET {base}/healthz` | Readiness from your `health` callback |
943
- | `GET /.well-known/oauth-protected-resource{/path}` | OAuth metadata, when `oauth` is set |
944
- | `{base}/v1/…`, `GET {base}/openapi.json` | [REST API](#rest-and-openapi) and its OpenAPI 3.1 document, when `rest` is on |
945
-
946
- ### Tools
947
-
948
- | Tool | Does | Hints |
949
- | --- | --- | --- |
950
- | `slotlock_list_resources` | Resources visible to the tenant | read-only |
951
- | `slotlock_get_free_busy` | Busy intervals plus coverage certainty | read-only |
952
- | `slotlock_find_next_available` | Earliest *certain* slot across resources | read-only |
953
- | `slotlock_find_available_slots` | Up to 50 *certain* slots, earliest first, to offer a choice | read-only |
954
- | `slotlock_hold_slots` | Hold up to 5 candidates for 30-900 seconds while a person chooses | write |
955
- | `slotlock_release_slots` | Release a hold and the rest of its group | write |
956
- | `slotlock_book_together` | Book the same time on 2-20 resources, all or none | write |
957
- | `slotlock_book_pool` | Book any one free member of a pool (a vehicle class, a room type) | write |
958
- | `slotlock_get_pool_availability` | How many of a pool's members are free across a window | read |
959
- | `slotlock_list_events` | Events the caller created in a window | read-only |
960
- | `slotlock_get_event` | One event the caller created | read-only |
961
- | `slotlock_create_event` | Create an event of up to 3,660 days (idempotent) | write |
962
- | `slotlock_update_event` | Patch at an expected revision, optionally fenced to `expected_resource_id` | write |
963
- | `slotlock_delete_event` | Tombstone at an expected revision | write, destructive |
964
-
965
- A query window is at most 367 days. Each caller sees only the events it created, but everyone's
966
- events count as busy time.
967
-
968
- An agent offering a person several times holds them first, so none is taken while the person
969
- decides: `slotlock_hold_slots` holds each free candidate in one group (and lists the taken ones in
970
- `refused`), and `slotlock_create_event` with one `hold_id` books that time and releases the rest in
971
- the same transaction. A hold belongs to the agent that took it: another agent can neither book nor
972
- release it. One agent may have 20 live holds at once, holding at most 2,160 hours together in
973
- `slotlock serve` (`SLOTLOCK_LIMITS`: `holder_holds`, `holder_held_hours`; `maxHoldsPerHolder` and
974
- `maxHeldHoursPerHolder` in `createSlotlockStore`); past either it is refused, `hold_limit_reached` or
975
- `held_time_limit_reached`. Unbooked holds expire by the database clock.
976
- Notice was judged when the time was held, so booking the hold does not judge it again.
977
-
978
- A booking that needs several resources at once (a van and its driver, a room and its projector)
979
- uses `slotlock_find_available_slots` with `require_free` (every resource, or any n of them, free
980
- together, each judged by its own hours and rules) and books with `slotlock_book_together`: one event
981
- per resource in one transaction, all or none. A refusal names the `resource_id` that refused, on
982
- MCP and A2A in the error beside its `code`, on REST in the problem. Bundles take their resources
983
- in one order, so two racing over the same ones end with one booked, never a deadlock.
984
-
985
- Rentals sell a class, not a plate. Put interchangeable resources in a pool
986
- (`PUT /v1/resources/{id}/pool`, `setResourcePool`); agents see each resource's `pool`, list a pool's
987
- members with `slotlock_list_resources` and `pool`, and book the class with `slotlock_book_pool`,
988
- which takes the first member that is free and allows the booking, in one transaction: a class of
989
- five sells exactly five for a time, however many race (`pool_full` after that). To see how many are
990
- free, `slotlock_get_pool_availability` splits a window where that number changes (counting each
991
- member busy over its bookings' occupied time, buffers included, but not the buffers a new booking
992
- would keep, so a member it counts free may still refuse a booking that abuts another); to see which,
993
- search the members with `slotlock_find_available_slots` and `require_free: 1`: each slot names the
994
- 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
995
- are no longer advertised.
996
-
997
- ### Errors
998
-
999
- A refusal is a result the model can act on:
1000
-
1001
- - **MCP**: `isError: true` with text `{"error":{"code":"…"}}`.
1002
- - **A2A**: the reply's data part is `{"error":{"code":"…"}}`.
1003
-
1004
- | Code | Meaning |
1005
- | --- | --- |
1006
- | `invalid_arguments` | Input failed the schema, or a window is inverted or longer than 367 days. A schema failure adds `issues`, up to 20 `{"path":[…],"message":"…"}`, each naming a field by its path in the arguments (`["rules",0,"start_minutes"]`; `[]` is the arguments as a whole): in `error` on MCP and A2A, beside `code` in REST's problem details |
1007
- | `forbidden` | `authorize` refused the call |
1008
- | `rate_limited` | `consumeRateLimit` refused the call |
1009
- | `not_found`, `resource_not_found`, `event_not_found` | Not visible to this caller, or no such id: a resource id is the `id` `slotlock_list_resources` returns, not its `external_ref` |
1010
- | `overlap` | The time is taken |
1011
- | `revision_conflict`, `idempotency_conflict`, `event_cancelled` | Re-read, then retry with the current revision or a new key |
1012
- | `owner_event_quota_exceeded`, `owner_command_quota_exceeded` | Per-caller quota reached (see [Operations](#operations)) |
1013
- | `confirmation_required` | The write needs a person, and this client cannot ask one |
1014
- | `confirmation_declined`, `confirmation_cancelled` | The person said no, or dismissed the question |
1015
- | `invalid_cursor`, `invalid_event`, `invalid_window`, … | The store rejected a bounded input |
1016
- | `request_aborted`, `server_busy`, `store_inconsistent` | Transient; retry |
1017
-
1018
- #### Errors from your own backend
1019
-
1020
- Throw `new SlotlockAgentOperationError('snake_case_code', status)` and the agent receives the code.
1021
- Any other exception is a server fault, and its message never leaves the server: the agent gets
1022
- `internal_error`, and `onError(error, { operation })` gets the error, as it does a result outside
1023
- the operation's output schema, so you can log it. `slotlock serve` logs it as an `agent_error` line
1024
- on stderr.
1025
-
1026
- #### Protocol errors
1027
-
1028
- | Where | Error |
1029
- | --- | --- |
1030
- | HTTP | `401` with `WWW-Authenticate`, `403` disallowed origin, `413`/`415` bad body, `429` rate limit (MCP, REST) |
1031
- | MCP | `-32602` unknown tool, `arguments` that are not an object, or altered confirmation; `-32603` server fault; `-32601` unknown method |
1032
- | MCP 2026-07-28 | `-32020` header disagrees with body, `-32021` form elicitation not declared, `-32022` unsupported revision, `-31029` rate limit |
1033
- | A2A | `-32602` malformed message or parameters, `-32029` rate limit (`RATE_LIMITED`, in the 200 envelope like every A2A error), `-32009` unsupported `A2A-Version`, `-32003` push notifications, `-32004` task subscription or extended card, `-32001` task not found (the server keeps no tasks) |
1034
-
1035
- ### Authentication and OAuth discovery
1036
-
1037
- `authenticate` checks whatever bearer token you issue. To let OAuth-capable MCP hosts find your
1038
- authorization server, publish RFC 9728 metadata:
1039
-
1040
- <!-- example: examples/oauth.ts#oauth -->
1041
- ```ts
1042
- import type { SlotlockAgentServerOAuthOptions } from 'slotlock';
1043
-
1044
- export const oauth: SlotlockAgentServerOAuthOptions = {
1045
- authorizationServers: ['https://auth.example.com'],
1046
- scopesSupported: ['calendar:read', 'calendar:write'],
1047
- requiredScopes: ['calendar:read'],
1048
- };
1049
- ```
1050
-
1051
- A `401` from `/mcp` then points clients at the metadata and asks for `requiredScopes`. Slotlock only
1052
- publishes it: `authenticate` must still validate each token, including its audience.
1053
-
1054
- ### Confirm before writing
1055
-
1056
- With `confirmation`, a listed write first asks the person a yes/no question that says exactly what
1057
- will change, such as `Book vehicle-42 for 2027-03-29 10:00–11:00 (Europe/London): "Vehicle
1058
- handover".` Only an explicit yes runs the write.
1059
-
1060
- - A booking that leaves the time free (`transparency: 'transparent'`) says so, and a write names
1061
- the other fields it sets (`also description, attendees`) without quoting them.
1062
- - Facts come first: the resource by its own reference, times in its own zone, and an event as it
1063
- stands now, read through your backend's `describeResource` and `getEvent` when the caller may
1064
- read them (within 2 seconds in all). Otherwise things are named by id, and each time carries its
1065
- UTC offset. The agent's own text comes last, quoted.
1066
- - A repeating booking says how many times it books, the first and the last, and each moved
1067
- occurrence, from the same expansion the store books with; a change to one says it covers every
1068
- repeat. Agents may send RFC 5545 rules that repeat daily or less often and can occur.
1069
- - The pending question is sealed (HMAC-SHA256) to the caller, the tool and the exact arguments, and
1070
- expires after `ttlSeconds` (600).
1071
- - Clients that cannot ask (MCP 2025 revisions, A2A) get `confirmation_required`; nothing is written.
1072
- - The yes comes from the client: Slotlock relies on the MCP client to ask its person, and a key
1073
- holder's own client can answer for itself. Confirmation keeps an agent from writing unasked; it is
1074
- not an authorization boundary. Scopes and `authorize` decide who may write.
1075
- - `secrets` holds 1 to 8 keys of 32+ bytes: list the new one first to rotate. `onEvent` reports each
1076
- outcome for audit.
1077
-
1078
- ### Calendar resources and live updates
1079
-
1080
- On MCP 2026-07-28 each resource is also an MCP resource, `slotlock://resources/{id}`, whose read
1081
- returns busy time and coverage for the next `resourceWindowDays` (30). `subscriptions/listen` streams
1082
- `notifications/resources/updated` when a resource's free/busy changes.
1083
-
1084
- - With `subscriptions.changes` (`createSlotlockStoreChangeSource(store, sql)`; `slotlock serve`
1085
- sets it), changes come from the tenant's change feed: one reader per tenant per process, shared by
1086
- all its subscriptions, polls the feed by cursor, one indexed query per read, under the tenant's
1087
- row-level security. It reads again 250 ms after a read that found changes
1088
- (`SLOTLOCK_FEED_BUSY_MS`, also the least gap between two reads) and backs off to once a second
1089
- when quiet (`SLOTLOCK_FEED_IDLE_MS`), so a booking is announced within about a second. A change
1090
- re-reads the subscriptions watching its resource, at most once a second each however many
1091
- changes arrive, and each announces only what actually changed in its window. A cursor the feed
1092
- has pruned past announces every watched resource once, then follows from now.
1093
- - Each subscription is also re-read every `pollIntervalMs` (15 s with a feed, 10 s without one,
1094
- which is then the only way changes are found), re-checking the caller each time, so a revoked key
1095
- or what the feed does not carry (bookable hours) is caught.
1096
- - A subscription ends after `maxDurationMs` (15 minutes), on `shutdown()`, or when access is lost.
1097
- - At most 4 open per caller and 256 per server (`maxPerPrincipal`, `maxTotal`). `maxPerTenant`
1098
- caps one tenant's across all its callers (default `maxTotal`; `slotlock serve` sets 16), so a
1099
- tenant with many keys cannot hold every slot. When the server is full, a tenant holding fewer
1100
- than the tenant holding most, less one, ends that tenant's oldest stream instead of being
1101
- refused, so many tenants cannot hold every slot either. `slotlock serve` sets `maxTotal` to
1102
- `subscriptions` (256) and lets one key hold `key_subscriptions` (4), and always fewer than its
1103
- tenant may unless that is one, so one key cannot lock its tenant's other keys out.
1104
- `subscriptions: false` turns them off.
1105
-
1106
- ### Caching and tracing
1107
-
1108
- 2026-07-28 results carry cache hints: discovery and tool lists are public for an hour, free/busy is
1109
- never cached. W3C `traceparent`, `tracestate` and `baggage` reach your backend as `context.trace`.
1110
-
1111
- `onEvent` hears about every call that names an operation, so you can count, time and correlate them:
1112
-
1113
- - `{ type: 'call', protocol, operation, outcome, durationMs, traceId? }` for an MCP `tools/call` or
1114
- A2A `SendMessage`; `outcome` is `ok`, the refusal's code (`overlap`, `rate_limited`, …), or why
1115
- it was refused before it ran: `invalid_arguments`, `missing_capability` (a guarded write from a
1116
- client that cannot confirm), `invalid_message` or `invalid_message_parameters` (A2A).
1117
- - `{ type: 'rest', operation, status, durationMs, traceId? }` for a REST call, or one refused before
1118
- it ran: a body that is not JSON (415, 400), too large (413) or not an object (400).
1119
- - `{ type: 'confirmation', operation, outcome }` for each confirmation, before the call it decides.
1120
- - A request refused before its operation is read is not reported: a missing credential, the rate
1121
- limit, an `A2A-Version` other than 1.0, a JSON-RPC envelope that does not parse, a path no route
1122
- has.
1123
- - `traceId` is the trace id of a valid `traceparent` from `_meta` or the header. Arguments, callers,
1124
- the parent span, `tracestate` and `baggage` are never reported, and an `onEvent` or `onError` that
1125
- throws, or returns a promise that rejects, changes nothing a client sees. Resource reads and
1126
- subscription polls are not reported.
1127
-
1128
- `slotlock serve` writes each as a JSON line (`"event":"call"`, `duration_ms`, `trace_id`).
1129
-
1130
- ### REST and OpenAPI
1131
-
1132
- `rest: true` also serves each tool as plain HTTP, described by an OpenAPI 3.1 document at
1133
- `/openapi.json`. `slotlock serve` has it on; `SLOTLOCK_REST_API=off` turns it off.
1134
-
1135
- | Request | Tool |
1136
- | --- | --- |
1137
- | `GET /v1/resources` | `slotlock_list_resources` |
1138
- | `GET /v1/free-busy` | `slotlock_get_free_busy` |
1139
- | `GET /v1/next-available` | `slotlock_find_next_available` |
1140
- | `GET /v1/available-slots` | `slotlock_find_available_slots` |
1141
- | `POST /v1/slot-holds` | `slotlock_hold_slots` |
1142
- | `DELETE /v1/slot-holds/{hold_id}` | `slotlock_release_slots` |
1143
- | `POST /v1/bundles` | `slotlock_book_together` |
1144
- | `POST /v1/pool-bookings` | `slotlock_book_pool` |
1145
- | `GET /v1/pool-availability` | `slotlock_get_pool_availability` |
1146
- | `GET /v1/events` | `slotlock_list_events` |
1147
- | `POST /v1/events` | `slotlock_create_event` |
1148
- | `GET /v1/events/{event_id}` | `slotlock_get_event` |
1149
- | `PATCH /v1/events/{event_id}` | `slotlock_update_event` |
1150
- | `DELETE /v1/events/{event_id}` | `slotlock_delete_event` |
1151
-
1152
- ```sh
1153
- curl "http://localhost:8080/v1/resources" -H "Authorization: Bearer $SLOTLOCK_API_KEY"
1154
- ```
1155
-
1156
- - `GET` and `DELETE` take the tool's arguments as query parameters (a list repeats:
1157
- `resource_ids=a&resource_ids=b`); `POST`, `PUT` and `PATCH` take a JSON body. Ids are in the path;
1158
- percent-encode one that holds `/`.
1159
- - Keys, scopes, `authorize` and the rate limit apply as over MCP. The body is the tool's output, or
1160
- an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem (`application/problem+json`) with
1161
- the matching HTTP status: `type` (a tag URI, one per code), `title`, `status`, `code`, and
1162
- `error.code` as well, the body earlier clients read. An `overlap` names the `resource_id`, `start`
1163
- and `end` it refused. A `Link: <…>; rel="describedby"` header points at these docs.
1164
- - A write's `idempotency_key` may come as the `Idempotency-Key` header instead
1165
- ([draft 08](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)): a
1166
- quoted Structured Field String (`Idempotency-Key: "booking-BK-42"`) or the bare key, equal to the
1167
- argument when both are sent. A write that needs one and gets neither answers
1168
- `400 idempotency_key_required`; the same key with another request, `422 idempotency_conflict`.
1169
- An event write's key is kept 30 days (or `SLOTLOCK_RETENTION_DAYS`), then removed by maintenance,
1170
- after which the key is new; a
1171
- hold's until the hold would have expired, is released or is confirmed.
1172
- - REST cannot ask a person, so a write listed in `confirmation` answers `428 confirmation_required`.
1173
- - Free/busy and the next-available search read each resource they name once, however often it is
1174
- named, and 10,000 busy intervals in all; the next-available search also expands 100,000 bookable
1175
- windows from its resources' hours in all. Past either they answer `400 calendar_work_limit`: ask
1176
- for fewer resources or a shorter window.
1177
- - There are no CORS headers: call it from a server, not a browser.
1178
- - From Python, Go, Rust, Java or .NET, use the [clients](clients/README.md): one method per
1179
- operation, the application API included, each tested against the server in CI. For another
1180
- language, generate one from [`openapi.json`](openapi.json) (or a server's `/openapi.json`, or
1181
- `slotlock/openapi.json` in the npm package) with any OpenAPI 3.1 generator.
1182
-
1183
- ### Application API
1184
-
1185
- What the application around the agents does, from any language: the store's resource, hours, hold,
1186
- reservation, coverage and maintenance calls, as REST beside the tools. It needs a key with the
1187
- `admin` scope ("Full" in the dashboard); the server's own `SLOTLOCK_AUTH_TOKEN`, the token agents
1188
- are given, reads and writes but is never admin. It is never an MCP tool or an A2A skill, and never
1189
- waits for a confirmation.
1190
-
1191
- | Request | Does |
1192
- | --- | --- |
1193
- | `POST /v1/resources` | Create the resource with this `external_ref`, or update its `timezone`; `visibility: "private"` hides it from agents |
1194
- | `GET /v1/resources/{resource_id}` | One resource, with its own weekly hours, visibility and `archived_at` |
1195
- | `POST /v1/resources/{resource_id}/archive` | Take it out of use; `…/restore` puts it back |
1196
- | `DELETE /v1/resources/{resource_id}` | Delete it with its history (`409 resource_in_use` while anything on it is live) |
1197
- | `GET /v1/resources/{resource_id}/busy?start=…&end=…` | Its occupied time, private resources included |
1198
- | `PUT /v1/resources/{resource_id}/availability` | `{"rules":[…]}`: its own weekly hours; `"rules":[]` never bookable, `"rules":null` the server's |
1199
- | `PUT /v1/availability` | `{"rules":[…]}`: every resource's weekly hours at once |
1200
- | `POST /v1/holds` | Hold a window for `ttl_seconds` (up to 7 days) |
1201
- | `GET /v1/holds/{hold_id}` | One live hold; `GET /v1/resources/{resource_id}/holds` lists them |
1202
- | `POST /v1/holds/{hold_id}/confirm` | Turn the hold into a reservation |
1203
- | `DELETE /v1/holds/{hold_id}` | Release the hold |
1204
- | `GET /v1/reservations/{reservation_ref}` | The booking at its current revision |
1205
- | `GET /v1/resources/{resource_id}/reservations?start=…&end=…` | Its bookings in a window |
1206
- | `PUT /v1/reservations/{reservation_ref}` | Book (`revision: 1`) or move (the current revision plus 1) |
1207
- | `DELETE /v1/reservations/{reservation_ref}?revision=…` | Cancel, at the current revision plus 1 |
1208
- | `PUT /v1/resources/{resource_id}/coverage/{source}` | Record that `source`'s events are complete for a window |
1209
- | `GET /v1/tenant/events?resource_id=…&start=…&end=…` | Every key's events on a resource, with the `id` the next two take |
1210
- | `GET /v1/tenant/events/{event_id}` | One of them |
1211
- | `DELETE /v1/tenant/events/{event_id}?expected_revision=…` | Cancel it, whichever key wrote it |
1212
- | `GET /v1/tenant` | The key's tenant, and whether it is suspended |
1213
- | `POST /v1/tenant/erase` | Erase the tenant, when the server allows it |
1214
- | `POST /v1/maintenance` | Run [maintenance](#operations) for the key's tenant |
1215
-
1216
- ```sh
1217
- curl -X PUT "http://localhost:8080/v1/reservations/BK-42" \
1218
- -H "Authorization: Bearer $SLOTLOCK_ADMIN_KEY" -H "Content-Type: application/json" \
1219
- -d '{"resource_id":"<id>","start":"2027-03-29T09:00:00Z","end":"2027-03-31T09:00:00Z",
1220
- "buffer_after_minutes":60,"revision":1}'
1221
- ```
1222
-
1223
- - A taken window answers `409 overlap`. `buffer_after_minutes` keeps the time after a reservation
1224
- busy for holds and other reservations alike.
1225
- - Every write is safe to retry: the same reservation at its current revision answers
1226
- `replayed: true`, a confirmed hold confirms again, and a cancelled reference stays cancelled (a
1227
- late booking for it answers `409`). A late copy of a booking or a move that has since been
1228
- superseded answers `409 stale_revision`.
1229
- - A confirmed hold is the booking whose reference is the hold's id, at revision 1: cancel it with
1230
- `DELETE /v1/reservations/{hold_id}?revision=2`.
1231
- - `POST /v1/holds` and `DELETE /v1/tenant/events/…` take a retry identity as the `Idempotency-Key`
1232
- header or as `idempotency_key`. A retried hold answers the hold the first request made; the same
1233
- key with another request is `422 idempotency_conflict`. Other routes are safe to retry as they
1234
- are, and refuse the header (`400 idempotency_key_not_supported`).
1235
- - Lists page by `limit` (up to 100) and `cursor`, the `next_cursor` of the page before, and read a
1236
- window of at most 367 days.
1237
- - The time a calendar event occupies is the event's: reservation calls never find, move or cancel
1238
- it, whatever reference they name. Change the event instead, or cancel it as the tenant with
1239
- `DELETE /v1/tenant/events/{event_id}`, which needs its revision and an idempotency key, and
1240
- takes `expected_resource_id` to fence it to the resource you read it on.
1241
- - Erasing a tenant over the API is off unless the backend is created with `tenantErasure: true`
1242
- (`slotlock serve` leaves it off: its operator runs `slotlock tenant erase`). It needs
1243
- `confirm_tenant_ref` to be the tenant's reference (`GET /v1/tenant`), deletes every key the tenant
1244
- has, the caller's included, and is refused while the tenant is suspended. Suspending a tenant is
1245
- never an API call: only the schema owner may, and a tenant could not lift its own.
1246
- - The tenant is the key's. Another tenant's resource or hold is `404`, whatever the database role.
1247
- - With the library, pass `application` beside `rest: true`:
1248
- `{ backend: createSlotlockStoreApplicationBackend(store, { maxTenantResources }), authorize }`.
1249
- A principal with `scopes` needs `admin`; then `authorize` decides.
1250
-
1251
- ### Changes and webhooks
1252
-
1253
- Every committed change to a tenant's holds, bookings, calendar events and configuration is recorded
1254
- in the same transaction (by a database trigger, so no write path skips it), and nothing is recorded
1255
- that did not commit. A change carries ids, times and states, never titles, descriptions, attendees,
1256
- webhook URLs or secrets.
1257
-
1258
- - The types: `hold.created`, `.updated`, `.confirmed`, `.released`, `.expired`;
1259
- `reservation.created`, `.updated`, `.cancelled`, `.checked_in`, `.no_show`; `event.created`,
1260
- `.updated`, `.cancelled`, `.timezone_shifted`, `.timezone_conflict`; `waitlist.offered`,
1261
- `.expired`; `coverage.stale`; and configuration: `resource.created`, `resource.updated` (booking
1262
- and check-in rules, pool, bookable hours, time zone or reference, with `before` and `after` of
1263
- only the fields that changed), `resource.archived`, `.restored`, `.deleted`; `coverage.recorded`
1264
- (a source's first read; reading it again is not a change) and `coverage.deleted`. A `coverage.*`
1265
- change names its source by `source_id` (also its `subject`), never the source itself, which can
1266
- be an iCalendar URL with a token or an address: `src_` and 32 random hex digits, made when the
1267
- source is first recorded for the resource and kept until it is deleted, so it cannot be worked
1268
- out from the source. `slotlock_get_coverage` (`listCoverage`) gives it beside the source, to
1269
- match a change to its source. A change about a source deleted before 0.6 named it is
1270
- `src_redacted`;
1271
- `webhook.created`, `.enabled`, `.disabled`, `.deleted` (the id and `event_types`, never the URL);
1272
- `key.created`, `.rotated`, `.revoked` (the id, scopes and expiry, never the key, its digest,
1273
- prefix or name). `SLOTLOCK_CHANGE_TYPES` lists them.
1274
- - Each change records who made it (`actor`: the API key or agent), the API key's id (`key_id`) when
1275
- a key made it, and where it came from (`origin`): `mcp`, `a2a`, `rest` (the REST agent routes),
1276
- `application` (the application API), `dashboard`, `dashboard-assistant`, `maintenance`, or
1277
- `library`: code calling the store itself, inside `withTenant` with no `origin` or outside it, and
1278
- any write that names none. Never an address or anything else about a person. A change an earlier
1279
- release recorded has neither (`null`).
1280
- - `key_id` is pseudonymous: the key's id, never the key or its name. It stays on the changes the key
1281
- made after the key is revoked or deleted, for as long as the feed keeps them (the retention below,
1282
- and longer while an enabled webhook has still to receive them); erasing the tenant removes them.
1283
-
1284
- - `GET /v1/changes?cursor=…` lists them oldest first as [CloudEvents 1.0](https://cloudevents.io)
1285
- (`io.pylota.slotlock.hold.created`, `.reservation.cancelled`, `.event.updated`, …), with the
1286
- extension attributes `slotlockorigin` and `slotlockkeyid` when the change records them. Follow
1287
- `next_cursor`, which comes back even when nothing is new: it never skips a change, even one whose
1288
- transaction commits after a later one's. Changes are kept 30 days (`SLOTLOCK_RETENTION_DAYS`,
1289
- `retentionDays` in `createSlotlockStore`, 1-3,650, for `serve` and `maintain` alike); a cursor
1290
- that a pruned change came after, whichever server pruned it, answers `410 cursor_expired`. A change keeps who made
1291
- it (`actor`) and a booking's or waitlist entry's reference: keep references opaque, not emails,
1292
- when the feed is kept long. A shorter retention never shortens how long a retried write is
1293
- answered as the first was: that stays at least 30 days.
1294
- - `POST /v1/webhooks` with an https `url` (and `event_types`, optionally) sends each change from
1295
- then on as a [Standard Webhooks](https://www.standardwebhooks.com) request: `webhook-id` (the
1296
- change's id, the same on every retry), `webhook-timestamp` and `webhook-signature`, keyed by the
1297
- `whsec_` secret the call returns once. `event_types` takes any of the types above. Verify it with any Standard Webhooks library, and use
1298
- `webhook-id` to drop a repeat: delivery is at least once.
1299
- - Changes go to each webhook in order, one at a time, until one fails; that one is retried after
1300
- 5 seconds, 5 minutes, 30 minutes, 2, 5, then every 10 hours. After a day of failures, or a `410`,
1301
- the webhook is disabled and the feed records `webhook.disabled`; `POST /v1/webhooks/{id}/enable`
1302
- resumes it where it stopped, or answers `409 changes_pruned` when what it missed is older than the
1303
- feed keeps (add it again and read what it missed from `/v1/changes`). The feed keeps every change
1304
- an enabled webhook has still to receive, however old. At most 10 webhooks per tenant.
1305
- - Each request has 15 seconds in all, and a tenant's pass 30 seconds; tenants are delivered 8 at
1306
- a time, so a slow endpoint holds up only its own tenant.
1307
- - History reads the same record backwards, with who made each change (`actor`, `key_id`), where
1308
- from (`origin`) and, for a change of an existing booking, event or resource, what it was before:
1309
- `GET /v1/resources/{id}/history` (its bookings, events and its own configuration),
1310
- `GET /v1/reservations/{reference}/history`, `GET /v1/tenant/events/{id}/history`, newest first,
1311
- paged by `before`. In the library, pass `actor`, `origin` and `keyId` to `withTenant` (`origin`
1312
- defaults to the enclosing call's, else `library`; `createSlotlockApiKeyAuthenticator` gives the
1313
- principal its `keyId`), and `actor` and `origin` to the API key store's `create`, `rotate` and
1314
- `revoke`; read `listHistory`. It is kept as long as the feed and erased with
1315
- the tenant.
1316
- - When time zone rules change (a new tzdata), a booking's instant and the wall time it was made for
1317
- part: Winnipeg's 09:00 is no longer the instant stored. Slotlock keeps both, and maintenance finds
1318
- future one-off events whose wall time moved: it reports each once (`event.timezone_shifted`, with
1319
- the instant it has and the one its wall time now means). The library's `followZoneRules` option
1320
- to `maintainSlotlockTenant` moves them instead, as a new revision, unless that overlaps another
1321
- booking (`event.timezone_conflict`; it stays, for a person or an agent to decide). `/healthz`
1322
- reports the `tzdata` the server runs.
1323
- - Feed health: `GET /v1/resources/{id}/coverage` shows each source (and its `source_id`, as the feed
1324
- names it), its window, when it was last read,
1325
- its last error (`POST /v1/resources/{id}/coverage/{source}/failure` records one) and whether it is
1326
- stale (not read for a day, so the time it covers is unproven, never free). A source that goes stale
1327
- is put on the feed once, as `coverage.stale`, until it is read again. Retire a sync you have
1328
- disconnected or replaced with `DELETE /v1/resources/{id}/coverage/{source}` (`deleteCoverage`):
1329
- nothing waits on it after that, `requireCertain` included.
1330
- - `slotlock serve` delivers (`SLOTLOCK_WEBHOOKS`): never to an address on a private network, which
1331
- it checks when it connects, and following no redirect. `private` allows those, for testing.
1332
- - In the library: `listChanges`, `createWebhookEndpoint` and the rest are store methods, and
1333
- `startSlotlockWebhookDelivery` with `deliverSlotlockWebhooks` runs delivery in your own process.
88
+ The [TypeScript library](https://slotlock.pylota.io/docs/library/) guide sets up PostgreSQL, then
89
+ books, holds and reads time under row-level security.
1334
90
 
1335
91
  ## Connect an agent
1336
92
 
1337
- MCP and A2A are wire protocols, so an agent in any language connects with its own MCP or A2A client
1338
- to `/mcp` or `/a2a`, the API key as a bearer token. The examples below use TypeScript. Code that
1339
- calls Slotlock directly, without an agent, can use the [REST clients](clients/README.md) for
1340
- Python, Go, Rust, Java and .NET.
1341
-
1342
- ### MCP (any client)
1343
-
1344
- <!-- example: examples/mcp-client.ts#mcp -->
1345
- ```ts
1346
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
1347
- import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
1348
-
1349
- export async function findSlotOverMcp(mcpUrl: string, token: string, resourceId: string) {
1350
- const client = new Client({ name: 'fleet-assistant', version: '1.0.0' });
1351
- const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
1352
- requestInit: { headers: { Authorization: `Bearer ${token}` } },
1353
- });
1354
- await client.connect(transport);
1355
- try {
1356
- const result = await client.callTool({
1357
- name: 'slotlock_find_next_available',
1358
- arguments: {
1359
- resource_ids: [resourceId],
1360
- start: '2027-03-29T00:00:00Z',
1361
- end: '2027-04-05T00:00:00Z',
1362
- duration_minutes: 120,
1363
- },
1364
- });
1365
- if (result.isError) {
1366
- const [text] = result.content as Array<{ type: 'text'; text: string }>;
1367
- return { error: JSON.parse(text?.text ?? '{}').error as { code: string } };
1368
- }
1369
- return { slot: result.structuredContent };
1370
- } finally {
1371
- await client.close();
1372
- }
1373
- }
1374
- ```
1375
-
1376
- A refusal or conflict is a result (`isError`), not an exception. Slotlock speaks MCP 2026-07-28 to
1377
- clients that ask for it and 2025-11-25 or 2025-06-18 to the rest.
1378
-
1379
- ### MCP 2026-07-28: confirmation and live updates
1380
-
1381
- With the TypeScript SDK v2 (`@modelcontextprotocol/client`), a client shows Slotlock's confirmation
1382
- to the person and hears about changes:
1383
-
1384
- <!-- example: examples/mcp-live.ts#live -->
1385
- ```ts
1386
- import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
1387
- import { slotlockCalendarResourceUri } from 'slotlock';
1388
-
1389
- export interface LiveCalendarOptions {
1390
- mcpUrl: string;
1391
- token: string;
1392
- resourceId: string;
1393
- confirm(message: string): Promise<boolean>;
1394
- onChange(uri: string): void;
1395
- }
1396
-
1397
- export async function openLiveCalendar(options: LiveCalendarOptions) {
1398
- const client = new Client(
1399
- { name: 'fleet-assistant', version: '1.0.0' },
1400
- { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } },
1401
- );
1402
- client.setRequestHandler('elicitation/create', async (request) =>
1403
- (await options.confirm(request.params.message))
1404
- ? { action: 'accept' as const, content: { confirm: true } }
1405
- : { action: 'decline' as const },
1406
- );
1407
- client.setNotificationHandler('notifications/resources/updated', (notification) =>
1408
- options.onChange(notification.params.uri),
1409
- );
1410
- await client.connect(
1411
- new StreamableHTTPClientTransport(new URL(options.mcpUrl), {
1412
- requestInit: { headers: { Authorization: `Bearer ${options.token}` } },
1413
- }),
1414
- );
1415
-
1416
- const uri = slotlockCalendarResourceUri(options.resourceId);
1417
- const subscription = await client.listen({ resourceSubscriptions: [uri] });
1418
- return {
1419
- watching: subscription.honoredFilter.resourceSubscriptions ?? [],
1420
- book: (booking: Record<string, unknown>) =>
1421
- client.callTool({
1422
- name: 'slotlock_create_event',
1423
- arguments: { resource_id: options.resourceId, ...booking },
1424
- }),
1425
- read: async () => {
1426
- const [content] = (await client.readResource({ uri })).contents;
1427
- return content && 'text' in content ? JSON.parse(content.text) : null;
1428
- },
1429
- close: async () => {
1430
- await subscription.close();
1431
- await client.close();
1432
- },
1433
- };
1434
- }
1435
- ```
1436
-
1437
- ### Claude Code and the Claude API
1438
-
1439
- **Claude Code:**
93
+ Point any Streamable HTTP MCP client at `http://localhost:8080/mcp` with the token (on Slotlock
94
+ Cloud, `https://slotlock-api.pylota.io/mcp` with an API key). Claude Code:
1440
95
 
1441
96
  ```sh
1442
- claude mcp add --transport http slotlock https://calendar.example.com/slotlock/mcp \
1443
- --header "Authorization: Bearer $SLOTLOCK_API_KEY"
1444
- ```
1445
-
1446
- **Claude API** (MCP connector, `anthropic-beta: mcp-client-2025-11-20`; the server must be public
1447
- over HTTPS):
1448
-
1449
- ```json
1450
- {
1451
- "model": "<model id>",
1452
- "max_tokens": 1024,
1453
- "mcp_servers": [
1454
- {
1455
- "type": "url",
1456
- "url": "https://calendar.example.com/slotlock/mcp",
1457
- "name": "slotlock",
1458
- "authorization_token": "<token your authenticate accepts>"
1459
- }
1460
- ],
1461
- "tools": [{ "type": "mcp_toolset", "mcp_server_name": "slotlock" }],
1462
- "messages": [{ "role": "user", "content": "When is vehicle 42 free for two hours next week?" }]
1463
- }
1464
- ```
1465
-
1466
- ### A2A 1.0
1467
-
1468
- <!-- example: examples/a2a-client.ts#a2a -->
1469
- ```ts
1470
- import { SendMessageRequest } from '@a2a-js/sdk';
1471
- import { ClientFactory } from '@a2a-js/sdk/client';
1472
-
1473
- export async function listResourcesOverA2a(baseUrl: string, token: string) {
1474
- const client = await new ClientFactory().createFromUrl(baseUrl);
1475
- const reply = await client.sendMessage(
1476
- SendMessageRequest.fromJSON({
1477
- message: {
1478
- messageId: crypto.randomUUID(),
1479
- role: 'ROLE_USER',
1480
- parts: [
1481
- {
1482
- data: { skill: 'slotlock_list_resources', arguments: { limit: 10 } },
1483
- mediaType: 'application/json',
1484
- },
1485
- ],
1486
- },
1487
- }),
1488
- { serviceParameters: { Authorization: `Bearer ${token}` } },
1489
- );
1490
- const part = 'parts' in reply ? reply.parts[0] : undefined;
1491
- return part?.content?.$case === 'data' ? part.content.value : undefined;
1492
- }
1493
- ```
1494
-
1495
- Send one `application/json` data part, `{"skill": "<id>", "arguments": {…}}`, with header
1496
- `A2A-Version: 1.0`. Each skill in the agent card has an example; replies are synchronous.
1497
-
1498
- The card declares `streaming`. `SendStreamingMessage` with any skill streams its reply as one
1499
- message. With `slotlock_watch_resources` (`{"resource_ids": [...]}`, up to 20) it streams a watch:
1500
- a task in `TASK_STATE_WORKING` naming the resources it can see, then a status update
1501
- `{"resource_id", "uri"}` each time one's free/busy changes, from the same feed and under the same
1502
- caps as MCP subscriptions, until `TASK_STATE_COMPLETED` (its duration, shutdown) or
1503
- `TASK_STATE_FAILED` (access withdrawn). Closing the stream ends it; the task is not kept, so
1504
- `SubscribeToTask` and `GetTask` do not find it. Push notifications are not offered: a tenant's
1505
- changes reach a URL as signed webhooks.
1506
-
1507
- ### MCP Apps
1508
-
1509
- Read-only tools link to `ui://slotlock/calendar`, a self-contained calendar view that hosts
1510
- supporting MCP Apps (`io.modelcontextprotocol/ui`) render in a sandbox. It makes no network requests
1511
- and never writes.
1512
-
1513
- A person can pick a slot in it. After `slotlock_find_available_slots` or a certain
1514
- `slotlock_find_next_available`, the view shows a strip of days and each day's times by morning,
1515
- afternoon and evening, or the one next available time. Picking a time and pressing *Book this time*
1516
- sends one chat message naming the resource id and the slot's start and end. The agent books it with
1517
- `slotlock_create_event`, through confirmation if `SLOTLOCK_CONFIRM_WRITES` asks for it. The message
1518
- carries nothing else from the result. If the host refuses the message, the view shows the text to
1519
- send instead. Write tools are marked `visibility: ["model"]`, so a host never lets a view call them.
1520
-
1521
- The view takes its look from the host: its light or dark theme and its standard MCP Apps style
1522
- variables (colours, fonts, sizes). It shows times in the person's locale, clock and time zone, with
1523
- a 12h/24h switch.
1524
-
1525
- ## Operations
1526
-
1527
- Every tenant needs maintenance at least daily: it extends recurring events to the rolling horizon
1528
- and prunes what the retention window no longer needs, which is what returns agent quota.
1529
-
1530
- - `slotlock serve` maintains `SLOTLOCK_TENANT` when it starts and every tenant whose key it serves,
1531
- then each hourly, one tenant at a time. It logs a `maintenance` line with counts.
1532
- - `slotlock maintain` runs one tenant's maintenance now, for cron or after a bulk import. An admin
1533
- key can call `POST /v1/maintenance`.
1534
- - Each pass holds a per-tenant PostgreSQL advisory lock, so servers and cron jobs never repeat each
1535
- other's work; a pass that finds the lock taken does nothing.
1536
-
1537
- In a process of your own:
1538
-
1539
- <!-- example: examples/maintenance.ts#maintenance -->
1540
- ```ts
1541
- import { type SlotlockStore, maintainSlotlockTenant } from 'slotlock';
1542
-
1543
- export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1544
- // Extends recurring events to the rolling horizon, then prunes past the retention window, each in
1545
- // at most ten batches. `capped: true` means work was left: run another pass.
1546
- return maintainSlotlockTenant(store, tenantRef, { batches: 10 });
1547
- }
97
+ claude mcp add --transport http slotlock http://localhost:8080/mcp \
98
+ --header "Authorization: Bearer $SLOTLOCK_AUTH_TOKEN"
1548
99
  ```
1549
100
 
1550
- - **Every tenant.** Forced RLS hides other tenants from the application role, so iterate your own
1551
- tenant list. `startSlotlockMaintenance({ store, sql, tenants, intervalMs })` is the scheduler
1552
- `serve` uses, lock included; `withSlotlockMaintenanceLock` is the lock alone.
1553
- - **Capped passes.** A capped pass that extended and pruned nothing met only series it cannot extend
1554
- (counted in `conflicts` and `refused`). The roll puts each behind every series it has not put
1555
- back, for an hour, so with `rollPending: true` (it stopped before those) run another pass at once:
1556
- it meets new ones, and a healthy series is reached however many are stuck. Otherwise another pass
1557
- would meet the same ones, so wait for the next interval.
1558
- - **Paging the roll yourself.** `rollCalendarEventHorizon` returns `next`; pass it as `after` to the
1559
- next batch of the same pass, so later batches go on from the last series examined. Series the
1560
- roll has never put back come first, then those it has, longest waiting first.
1561
- - **Quotas.** Each agent principal may retain 1,000 event identities (active plus cancelled) and
1562
- 10,000 idempotency commands, of which 9,000 can be creates or updates, so a cancel always has
1563
- room. Adjust with `agentOwnerEventQuota` and `agentOwnerCommandQuota` on `createSlotlockStore`.
1564
- `pruneCalendarEventRetention` is what returns quota.
1565
- - **Retention.** Commands are exact-replay evidence for the replay window, by default
1566
- `SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS` (30) days: pruning deletes older commands for every owner,
1567
- then agent-owned tombstones older than the window with no command left. `internal` tombstones
1568
- (provider and sync authority) are kept until you erase the tenant. After the window a retried
1569
- command is evaluated afresh under its expected revision, and a pruned agent identity can be
1570
- created again. `retentionDays` (1-3650) changes the window; keep it longer than any client retries.
1571
- - **Erasure.** `slotlock tenant erase --confirm <tenant>`, or the store's `eraseTenant({ tenantRef })`,
1572
- deletes everything the tenant owns in one transaction, waiting for its transactions in flight:
1573
- resources, holds, bookings, events with their attendees and reminders, cancellations,
1574
- idempotency records, coverage, API keys, and a personal tenant's dashboard records. Call it from
1575
- your own erasure workflow; nothing is deleted table by table. Only the API keys' SHA-256 digests
1576
- stay, with nothing about the tenant, so an erased key is never accepted again. The dashboard's
1577
- other records hold no tenant and expire within 12 hours; a form record carries a SHA-256 digest
1578
- of the GitHub user id that sent it, and an accepted write confirmation a digest of its nonce.
101
+ Cursor, VS Code, the Claude API and the MCP and A2A SDKs are in
102
+ [Connect an agent](https://slotlock.pylota.io/docs/connect/), and every `slotlock_*` tool's input,
103
+ output and errors in the [Tools reference](https://slotlock.pylota.io/docs/tools/).
1579
104
 
1580
- ### Production checklist
105
+ ## Documentation
1581
106
 
1582
- 1. PostgreSQL with TLS, backups and point-in-time recovery.
1583
- 2. Deploy as the owner; serve as a `NOBYPASSRLS` role granted by `grantApplicationRole`
1584
- (`slotlock serve` refuses a superuser or `BYPASSRLS` role).
1585
- 3. Run every call inside `withTenant`; test isolation with a `NOBYPASSRLS` role.
1586
- 4. Verify provider webhooks before normalizing them; keep provider credentials out of Slotlock.
1587
- 5. Bound retries on serialization failures; never blindly retry an unknown write.
1588
- 6. Reconcile external calendars with durable cursors and coverage windows.
1589
- 7. Monitor conflicts, refused rules, sync lag, capped maintenance runs, quota errors and confirmation outcomes.
1590
- 8. Keep writes confirmation-gated wherever your policy needs a person (`slotlock serve`:
1591
- `SLOTLOCK_CONFIRM_WRITES`, `none` by default).
1592
- 9. Size live updates: one indexed feed query per second per tenant with an open subscription (four
1593
- a second while its feed is busy), at most one backend read a second per subscription a change
1594
- touches, and at most `maxTotal` backend reads per `pollIntervalMs`.
1595
- 10. Give each agent its own API key with the narrowest scope and an expiry.
1596
- 11. After each deployment, check it end to end with a short-lived admin key, then revoke the key:
1597
- `SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs`, from a clone
1598
- of the repository, checks health,
1599
- discovery, MCP, A2A, holds and reservations (in a resource named `slotlock-smoke`, 400 days
1600
- ahead, cancelled after) and the 401, 409, 413 and 415 refusals.
1601
- 12. Rate-limit every public server. `slotlock serve` does it per client address, key, tenant and
1602
- server (`SLOTLOCK_LIMITS`); behind a proxy, set `SLOTLOCK_TRUSTED_PROXY_HEADER` and firewall
1603
- the server so nothing reaches it except through that proxy. Embedding the
1604
- agent server, give it a `consumeRateLimit`. Watch serve's `refusals` log line for floods.
1605
- 13. On an upgrade, migrate first, then, as a library user, run `grantApplicationRole` again right
1606
- after `applySchema` and before serving traffic: a release that adds a table (the leading-buffer
1607
- release adds `booking_rule_defaults`, which every booking reads) fails every booking with a
1608
- permission error until the serving role is granted it. `slotlock migrate` does both. A migration
1609
- that rebuilds a constraint holds its table's lock for the whole build (see the CHANGELOG's
1610
- Upgrading notes).
1611
- 14. Bound how long a transaction may run. A store over your own transaction (a savepoint per call)
1612
- keeps its locks and its snapshot for as long as yours runs, and a hold you confirm inside it is
1613
- judged by the statement's clock, not the transaction's. On PostgreSQL 17 and later, give the
1614
- serving role a `transaction_timeout` above your longest legitimate transaction
1615
- (`ALTER ROLE slotlock_app SET transaction_timeout = '60s'`): PostgreSQL ends one that runs
1616
- longer, rolling back what it did, and closes its connection (the pool opens another). On 16, set `idle_in_transaction_session_timeout`
1617
- and `statement_timeout`, which bound idle gaps and single statements but not the whole.
107
+ | For | Read |
108
+ | --- | --- |
109
+ | Running it | [Quickstart](https://slotlock.pylota.io/docs/quickstart/), [Self-hosting](https://slotlock.pylota.io/docs/self-host/), [Slotlock Cloud](docs/hosted/README.md) |
110
+ | API keys and scopes | [API keys](https://slotlock.pylota.io/docs/keys/) |
111
+ | Bookable hours and booking rules | [Hours and booking rules](https://slotlock.pylota.io/docs/booking-rules/) |
112
+ | The dashboard, organizations and the assistant | [Dashboard](https://slotlock.pylota.io/docs/dashboard/), [on Cloudflare Workers](https://slotlock.pylota.io/docs/dashboard-workers/) |
113
+ | Agents: tools, errors and protocols | [Connect an agent](https://slotlock.pylota.io/docs/connect/), [Tools reference](https://slotlock.pylota.io/docs/tools/), [Protocol reference](https://slotlock.pylota.io/docs/reference/) |
114
+ | REST, the application API, changes and webhooks | [REST API](https://slotlock.pylota.io/docs/api/), [Protocol reference](https://slotlock.pylota.io/docs/reference/#rest-and-openapi) |
115
+ | The library: store, holds, waitlists | [TypeScript library](https://slotlock.pylota.io/docs/library/) |
116
+ | Recurring events | [Recurring events](https://slotlock.pylota.io/docs/recurring-events/) |
117
+ | External calendars and iCalendar | [External calendars](https://slotlock.pylota.io/docs/external-calendars/) |
118
+ | Rate limits and tenant limits | [Limits](https://slotlock.pylota.io/docs/limits/) |
119
+ | Maintenance, retention and the production checklist | [Operations](https://slotlock.pylota.io/docs/operations/) |
120
+ | Scaling and benchmarks | [Scaling](https://slotlock.pylota.io/docs/scaling/), [Benchmarks](https://slotlock.pylota.io/docs/benchmarks/) |
121
+ | Clients for Python, Go, Rust, Java and .NET | [Clients](clients/README.md) |
122
+ | Concepts and the security model | [Concepts](https://slotlock.pylota.io/docs/concepts/), [Security](https://slotlock.pylota.io/docs/security/), [SECURITY.md](SECURITY.md) |
123
+ | The exact rules | [SPEC.md](SPEC.md) |
1618
124
 
1619
125
  ## Supported versions
1620
126