Back to Browse

Slotlock MCP Server

Developer ToolsLow Risk10.0MCP RegistryLocal
Free

Server data from the Official MCP Registry

Calendars for AI agents: free/busy, next free slot, and bookings PostgreSQL never double-books.

About

Calendars for AI agents: free/busy, next free slot, and bookings PostgreSQL never double-books.

Security Report

10.0
Low Risk10.0Low Risk

Valid MCP server (2 strong, 0 medium validity signals). No known CVEs in dependencies. Imported from the Official MCP Registry. Trust signals: 4 highly-trusted packages.

12 files analyzed · No issues found

Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.

Permissions Required

This plugin requests these system permissions. Most are normal for its category.

env_vars

Check that this permission is expected for this type of plugin.

What You'll Need

Set these up before or after installing:

postgres:// URL of a PostgreSQL 16 or newer database the container can reach, as a role that is not a superuser and has no BYPASSRLS: serve refuses one, since row-level security would not confine it. Without SLOTLOCK_MIGRATE_DATABASE_URL this role also applies the schema, so it must be able to create it.Required

Environment variable: DATABASE_URL

Optional bearer token for SLOTLOCK_TENANT, with read and write (never admin): at least 32 random characters (openssl rand -hex 32). Leave it unset to accept only API keys (slotlock key create).Required

Environment variable: SLOTLOCK_AUTH_TOKEN

Signs pending write confirmations: at least 32 random characters, different from the token. Required unless SLOTLOCK_CONFIRM_WRITES is none.Required

Environment variable: SLOTLOCK_CONFIRMATION_SECRET

Serves Prometheus metrics at /metrics to this bearer token: at least 32 random characters, different from every other secret. Off without it.Required

Environment variable: SLOTLOCK_METRICS_TOKEN

Port the server listens on inside the container.Optional

Environment variable: PORT

postgres:// URL of the schema owner. When set, migration runs as this role and grants the DATABASE_URL role only what it needs at runtime.Required

Environment variable: SLOTLOCK_MIGRATE_DATABASE_URL

Writes that wait for a person to confirm them: none, all, or a comma-separated list of slotlock_create_event, slotlock_book_together, slotlock_book_pool, slotlock_update_event, slotlock_delete_event. Clients without MCP 2026-07-28 form elicitation have guarded writes refused.Optional

Environment variable: SLOTLOCK_CONFIRM_WRITES

JSON array of weekly bookable-hours rules, e.g. [{"rrule":"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR","startMinutes":540,"durationMinutes":480}]. Without rules no slot is ever offered.Optional

Environment variable: SLOTLOCK_AVAILABILITY

on: also serve the tools as a REST API under /v1, and the application API (resources, bookable hours, holds, reservations, coverage, maintenance) for admin keys, described by an OpenAPI 3.1 document at /openapi.json. Writes that wait for confirmation answer 428 there. off: MCP and A2A only.Optional

Environment variable: SLOTLOCK_REST_API

Days of history kept: the change feed (who made each change included) and how far back a cursor reaches, and ended waitlist entries; retried writes are answered for at least 30 days whatever it is. 1-3650; default 30.Optional

Environment variable: SLOTLOCK_RETENTION_DAYS

on: tenant request budgets (SLOTLOCK_LIMITS' tenant_* and app_*) are shared by every server of a deployment through PostgreSQL, so N servers allow a tenant its rate once, not N times. off: each server keeps its own.Optional

Environment variable: SLOTLOCK_SHARED_LIMITS

on: deliver each tenant's changes to its webhooks (POST /v1/webhooks), signed as Standard Webhooks; never to a private network address. private: those too, for testing. off: keep the change feed (GET /v1/changes), send nothing.Optional

Environment variable: SLOTLOCK_WEBHOOKS

The tenant SLOTLOCK_AUTH_TOKEN acts in; API keys act in their own tenant.Optional

Environment variable: SLOTLOCK_TENANT

How to Install

Add this to your MCP configuration file:

{
  "mcpServers": {
    "io-github-pilotaai-slotlock": {
      "env": {
        "PORT": "your-port-here",
        "DATABASE_URL": "your-database-url-here",
        "SLOTLOCK_TENANT": "your-slotlock-tenant-here",
        "SLOTLOCK_REST_API": "your-slotlock-rest-api-here",
        "SLOTLOCK_AUTH_TOKEN": "your-slotlock-auth-token-here",
        "SLOTLOCK_AVAILABILITY": "your-slotlock-availability-here",
        "SLOTLOCK_CONFIRM_WRITES": "your-slotlock-confirm-writes-here",
        "SLOTLOCK_CONFIRMATION_SECRET": "your-slotlock-confirmation-secret-here",
        "SLOTLOCK_MIGRATE_DATABASE_URL": "your-slotlock-migrate-database-url-here"
      },
      "args": [
        "-y",
        "slotlock"
      ],
      "command": "npx"
    }
  }
}

Documentation

View on GitHub

From the project's GitHub README.

Slotlock

Slotlock is a calendar for AI agents. It books vehicles, rooms, machines, people or anything else reservable, answers "when is this free?" from weekly availability rules, and lets PostgreSQL reject double bookings, however many writers race. Agents call it over MCP or A2A; your code calls it as a TypeScript library.

Why it is built for agents

  • Resources own calendars, not users, and the tenant comes only from the authenticated caller.
  • Intervals are half-open [start, end), so back-to-back bookings never collide.
  • A PostgreSQL exclusion constraint decides overlaps; a conflict comes back as data, not an error.
  • Writes are idempotent and take an expected revision.
  • Free/busy says whether it is certain: a calendar not yet synced makes time unproven, not free.
  • Tool names, schemas and errors work on Claude, OpenAI and every MCP host.

Install

npm install slotlock postgres

Node.js 22.12+, PostgreSQL 16 or newer with btree_gist (created for you if missing; CI runs 16, 17 and 18), and postgres ^3.4.5 as a peer dependency. TypeScript declarations are included.

Self-host with Docker

From a clone of this repository:

scripts/quickstart-env.sh > .env    # .env.example, its four secrets set to openssl rand -hex 32
docker compose up --build --detach
docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/London
URLWhat
http://localhost:8080/mcpMCP, with Authorization: Bearer <SLOTLOCK_AUTH_TOKEN>
http://localhost:8080/a2aA2A 1.0, same token; card at /.well-known/agent-card.json
http://localhost:8080/v1/…REST: the tools, and the application API for admin keys; described at /openapi.json
http://localhost:8080/healthzReadiness, including the database
http://localhost:8080/metricsPrometheus metrics, with Authorization: Bearer <SLOTLOCK_METRICS_TOKEN>; off without it
  • Metrics count calls by protocol, operation and outcome (slotlock_calls_total, with their duration in slotlock_call_duration_seconds), confirmations, subscriptions, refusals, maintenance and webhook deliveries. No label names a tenant, key or client. createSlotlockMetrics builds them for a server of your own, from its hooks.
  • The server runs as a role that owns nothing and cannot bypass row-level security; a one-off migrate service applies the schema as the owner.
  • SLOTLOCK_AVAILABILITY sets the default bookable hours. A resource can have its own (slotlock hours set, or the dashboard); with neither, no slot is offered. SLOTLOCK_CONFIRM_WRITES=all (as .env.example sets it; the server's default is none) makes every write wait for a person.
  • To serve beyond this machine, put a TLS proxy in front and set SLOTLOCK_PUBLIC_URL.
  • The server limits requests per client address (before any key is looked up), per key, per tenant and for the whole server, and answers 429 rate_limited with Retry-After and the RateLimit fields. The application API counts an admin key's calls in its tenant's own 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 re-expands a recurring event's series too) takes its write token, then waits for a slot: one at a time per tenant (tenant_series_writes) and per server (series_writes); each server process counts its own. A freed slot rests for as long as the server was busy while the write before held it (at most a second), so with one slot, writes that each hold it under a second keep the server busy at most about half the time. It then goes to the waiting tenant with the fewest writes in flight and, among those, the one whose writes have kept the server busy least lately (halving every ten seconds), then to the first to ask. So a tenant sending one write at a time waits for the write running, its rest, and the other tenants with one write in flight whose writes kept the server less busy: many such tenants, new ones included, can each go ahead of it once. Tenants keeping more writes waiting never go ahead of it. Four more of a tenant's writes may wait and the rest get 429. When the server's 64 places are all taken, a write from a tenant with fewer writes in flight than the tenant with the most takes the place of that tenant's last write, which gets 503 server_busy; otherwise it gets 503 at once. An expansion runs on the server's main thread, so any request may also wait for the one running. A write that has waited two seconds gets 429 while its tenant's own write is still running, otherwise 503 server_busy. An application API maintenance run waits the same way for slots of its own: one per tenant, maintenance_runs per server. The health check is not counted. One tenant's requests hold at most tenant_transactions (2) of the server's 10 database connections at once and the rest wait their turn, so a tenant's writes waiting on its own locks leave the others to other tenants and key lookups; a server with one tenant can set tenant_transactions=10. A request whose transaction waits more than 5 seconds for a lock gets 503 server_busy. With more than one server, SLOTLOCK_SHARED_LIMITS=on shares the tenant budgets (tenant_*, app_*) through the database, so N servers allow a tenant its rate once, not N times; each server checks its own buckets first, and goes on with them alone if the database cannot be reached. SLOTLOCK_LIMITS changes the limits; behind a proxy, set SLOTLOCK_TRUSTED_PROXY_HEADER to the field it puts the client's address in (CF-Connecting-IP behind Cloudflare) before the server takes traffic, or every client shares the proxy's budget: the server logs a client_address_field_ignored warning when such a field arrives without the setting. Behind two proxies, such as Cloudflare and then kamal-proxy, name the first one's field: the server reads the last address of a list field such as X-Forwarded-For, which the second proxy writes as Cloudflare's. A client can send that field itself, so let nothing reach the server except through the proxy.
  • The server also bounds what one tenant may store, whichever key, agent or application writes it: 10,000 holds, and 100,000 each of bookings, calendar events and coverage rows, counted as the store's tenantLimits count them, besides 10,000 resources. A write past one answers 409 hold_limit_reached, reservation_limit_reached, event_limit_reached or coverage_limit_reached; SLOTLOCK_LIMITS changes them (tenant_holds, tenant_reservations, tenant_events, tenant_coverage).
slotlock hours set '[{"rrule":"FREQ=WEEKLY;BYDAY=SA","startMinutes":600,"durationMinutes":240}]' --resource vehicle-42
slotlock hours clear --all    # back to SLOTLOCK_AVAILABILITY

A rule is rrule, startMinutes and durationMinutes here and in SLOTLOCK_AVAILABILITY. The REST application API spells every field in snake_case, so there it is rrule, start_minutes and duration_minutes. slotlock --help lists every setting.

Booking rules

A resource can also say what a booking on it must satisfy:

slotlock rules set '{"minNoticeMinutes":120,"slotStepMinutes":30,"turnaroundMinutes":30,"enforceHours":true}' --resource vehicle-42
slotlock rules clear --resource vehicle-42
RuleA booking made now
minNoticeMinutesstarts at least this far ahead
maxAdvanceDaysstarts at most this far ahead
slotStepMinutesstarts on a step from local midnight in the resource's time zone
stepOffsetMinutesstarts this far past each step (0 to the step less one): with a 30-minute step, 15 starts bookings on :15 and :45
bufferBeforeMinuteskeeps this long free before it: its leading buffer, which the database enforces
turnaroundMinuteskeeps this long free after it: its trailing buffer, which the database enforces
enforceHourslies inside the resource's bookable hours
checkInMinutesis claimed (checkInReservation) this long after its start, or maintenance releases it
requireCertainlies in time every calendar sync recording coverage for the resource has read within a day
  • Holds, reservations, moves and agents' events are checked when they are made, by the database's clock; a refusal names the rule (booking_rule_violated with rule in the store, booking_rule_min_notice, _max_advance, _slot_step, _outside_hours or _uncertain to an agent). Under requireCertain, a search (the store's and the tools') needs those syncs' coverage too, read within a day, of the slot and its turnaround, so it offers no slot they leave unproven.
  • A series is judged on its first occurrence for notice and advance, and on every occurrence for step and hours. Imported calendar events are not checked: they happened elsewhere.
  • Searches (slotlock_find_next_available, slotlock_find_available_slots, findNextAvailableFor) offer only slots the rules allow, so an offered slot is one the write accepts.
  • Two bookings stay at least the earlier one's trailing buffer plus the later one's leading buffer apart: each occupies from its start less its leading buffer to its end plus its trailing one, and the exclusion constraint keeps those apart. Free/busy reports that time as busy.
  • The REST application API sets them with PUT /v1/resources/{resource_id}/booking-rules, in snake_case; the store with setResourceBookingRules.
  • Defaults: rules for every resource of the tenant (PUT /v1/booking-rules) and for every member of a pool (PUT /v1/pools/{pool}/booking-rules), read back with GET /v1/booking-rules; the store's setBookingRuleDefaults and getBookingRuleDefaults. A resource's rules as they apply (effective_booking_rules, effectiveBookingRules) are the tenant's defaults, overlaid by its pool's, overlaid by its own: a rule set at a narrower level wins. checkInMinutes is a resource's own only. The dashboard's resource page shows which rules a resource inherits, and from where.
  • With no rule set anywhere, nothing changes: every rule is opt-in.
  • A tenant has default rules for at most 1,000 pools (pool_rules_limit_reached, 409, for one more). Merged rules are read leniently: a pool's stepOffsetMinutes under a resource's shorter step applies modulo that step, and a field that is invalid on its own is ignored (and logged), never read as refusing every booking.

Without Docker

With Node.js 22.12+ and PostgreSQL 16 or newer, run each command as npx slotlock after npm install slotlock postgres, or from a clone, built once, as node dist/cli.js (a clone does not put slotlock on your PATH). Use two roles, as Compose does, so two URLs: serve connects as DATABASE_URL, a role that owns nothing and cannot bypass row-level security, and migrate connects as SLOTLOCK_MIGRATE_DATABASE_URL, the schema owner, and grants that role what it needs. serve refuses to start as a superuser or a BYPASSRLS role, which row-level security does not confine, and warns (serving_as_schema_owner) when its role owns the schema.

npm ci && npm run build
# Once, as a superuser:
#   CREATE ROLE slotlock_app LOGIN PASSWORD '<app password>' NOSUPERUSER NOBYPASSRLS;
export SLOTLOCK_MIGRATE_DATABASE_URL='postgresql://<owner>:<owner password>@localhost:5432/slotlock'
export DATABASE_URL='postgresql://slotlock_app:<app password>@localhost:5432/slotlock'
export SLOTLOCK_AUTH_TOKEN="$(openssl rand -hex 32)"
export SLOTLOCK_CONFIRMATION_SECRET="$(openssl rand -hex 32)"
node dist/cli.js migrate
node dist/cli.js resource add vehicle-42 --timezone Europe/London
node dist/cli.js serve

The slotlock … commands in this README run the same way as node dist/cli.js … (with Compose, docker compose exec slotlock slotlock …).

API keys

Give each agent its own key instead of sharing the server token:

slotlock key create "Booking agent"
slotlock key create "Availability bot" --scope read --expires-in-days 90
slotlock key create "Booking site" --scope read,write,admin
slotlock key list
slotlock key rotate <id>
slotlock key revoke <id>
  • A key (slk_…) is shown once and stored only as a SHA-256. Send it as Authorization: Bearer.
  • It acts in the tenant it was created for (SLOTLOCK_TENANT), so one server serves many tenants.
  • read covers the seven tools that only look; write covers create, update and delete; admin covers the application API, which no agent needs. Default: read and write.
  • rotate keeps the key's id, scopes and bookings; revoke stops it on its next request.
  • A tenant may hold 100 active keys. The last six characters are a checksum secret scanners can verify offline.

Retiring resources and tenants

slotlock resource archive vehicle-42   # no new bookings, holds or events; history kept
slotlock resource restore vehicle-42
slotlock resource delete vehicle-42    # with its history; refused while anything on it is live
slotlock tenant status
slotlock tenant suspend --reason "chargeback review"
slotlock tenant unsuspend
slotlock tenant erase --confirm "$SLOTLOCK_TENANT"
  • The resource commands take a reference or an id, and act in SLOTLOCK_TENANT.
  • A suspended tenant may read and writes nothing; its keys still authenticate, but it gets no new key or secret. Only the schema owner suspends or lifts a suspension, so suspend and unsuspend connect as SLOTLOCK_MIGRATE_DATABASE_URL's role when it is set.
  • erase deletes everything the tenant has, its API keys included, at once and for good; --confirm must repeat the tenant. A suspension outlives it.

Dashboard

The server can also serve a dashboard where people sign in with GitHub and manage their own keys and resources, so you can give each person API keys without running slotlock key create for them:

  1. On GitHub, open Settings, Developer settings, OAuth apps, New OAuth App, and set the "Authorization callback URL" to <SLOTLOCK_PUBLIC_URL>/dashboard/callback. Then generate a client secret on the app's page.
  2. Set SLOTLOCK_GITHUB_CLIENT_ID and SLOTLOCK_GITHUB_CLIENT_SECRET from the app, SLOTLOCK_SESSION_SECRET to 32+ random characters (openssl rand -hex 32), and SLOTLOCK_DASHBOARD_USERS to the GitHub user ids that may sign in (gh api users/<login> --jq .id), or to * for every GitHub account.
  3. Run slotlock serve and open <SLOTLOCK_PUBLIC_URL>/dashboard. With Compose, set the four in .env (empty, the default, leaves the dashboard off) and run docker compose up --detach.
  • Its pages: an Overview with the coming week's bookings, events and live holds across every resource (each in its resource's time zone) and the latest changes agents made, named by key; Resources, each with its own page for bookable hours, booking rules, pool, coming bookings and activity; Waitlist, to see who waits and remove an entry; API keys, with the MCP and A2A endpoints; Webhooks, to add, delete and re-enable endpoints and see which are failing; and Settings. Everything on them is read from the tenant's own data. SLOTLOCK_DASHBOARD_NAME sets what the dashboard calls the deployment (Slotlock by default).
  • Each person gets a tenant of their own, github:<user id>: the keys, resources and bookings in it are theirs alone. Keys made with slotlock key create act in SLOTLOCK_TENANT instead.
  • For organizations instead of people, set SLOTLOCK_DASHBOARD_ORGS to the GitHub organization ids the dashboard serves (gh api orgs/<login> --jq .id). Each is then one tenant, github-org:<id>, that its members share: keys, resources and hours. A person signs in to the served organizations they are an active owner or member of (a billing manager is neither), switching between them when there are several; someone in none of them is refused. Every member has the same power: each can create, rotate and revoke the organization's keys, and the key list says who created each one. Membership is read at each sign-in, so a person who leaves keeps the dashboard until their session ends (12 hours at most), and keys they made work until revoked. Removing an id from the list signs its sessions out and stops its keys at once. An organization that restricts OAuth apps must approve the dashboard's app (its Settings, Third-party access), or GitHub hides the membership. SLOTLOCK_DASHBOARD_USERS still says who may sign in at all, and a key records the person who created it (github:<user id>): erasing that person's own tenant leaves it on keys they made in an organization, which go with the organization's erasure. Only the first 100 memberships GitHub lists, and the first 20 served organizations by login, are read.
  • Sign-in uses state and PKCE, and keeps nothing from GitHub but the user id and login (and, with organizations, the served ones the person belongs to): the GitHub token is used once and dropped. It asks for no scope, or read:org with organizations.
  • A session is a signed, HttpOnly cookie that lasts 12 hours. The allowlist is checked on every request, dashboard and API alike: removing an id and restarting the server signs that person out and stops every key in their tenant at once.
  • Every form carries a token bound to the session and must come from the server's own origin; the pages run no inline script, under a Content-Security-Policy that allows only their own files.
  • Give the dashboard an origin of its own (https://slotlock.example.com, not a path beside other apps): its cookies are host-wide, so any other app on the same origin could read them.
  • Finished sign-ins (a callback works once), signed-out sessions (a copied cookie stays out) and sent forms (a reload does not create or rotate a key twice) are recorded in Postgres, so they hold on every server that shares the database, behind any load balancer. Each is a SHA-256 digest, kept until its cookie or form expires (12 hours at most); a form record also carries a digest of the GitHub user id that sent it. The server role reaches them only through two SECURITY DEFINER functions, and serve refuses to start the dashboard until migrate has granted them.
  • The database's clock decides when a sign-in or session has expired, so servers whose clocks drift from it or from each other cannot let a replay or a signed-out session through. Keep clocks in sync all the same: a server more than 12 hours ahead of the database cannot sign anyone in.
  • Each form carries a single-use value bound to its session. A person may have 1,000 forms recorded at once (12 hours' worth); past that the dashboard answers 429 until the oldest expire. Signing out always works.
  • A person may add 100 resources. The database holds the cap, so adds racing on any number of servers stop at 100; a resource they already have can still change time zone.
  • Each resource uses the server's bookable hours (SLOTLOCK_AVAILABILITY) until its owner sets its own on the hours page: two windows a day, overnight or all day, or closed. One form sets them for every resource at once.
Assistant: operate Slotlock by chat

With SLOTLOCK_ASSISTANT_SECRET set (32+ random characters, openssl rand -hex 32), the dashboard gains an Assistant page where every member chats with a model their organization connects, instead of clicking. Each organization brings its own model:

  • Connect it in Settings, Assistant (owners only; with personal tenants, each person): the provider (Anthropic Messages API, or OpenAI-compatible, which covers OpenAI, OpenRouter, vLLM, Ollama and LM Studio), the base URL (https://api.anthropic.com, or the one ending in /v1: https://api.openai.com/v1, http://localhost:11434/v1; https, or http only on localhost or 127.0.0.1), the model name (your-model) and the API key, which a local model may not need. As with webhooks, a URL whose host is, or resolves to, a loopback, private, link-local, shared (CGNAT), unique-local or metadata address (169.254.169.254) is refused, when it is saved and at each connection (the address the socket connects to is checked, so a name rebound to one is refused too), and the page says why. For a self-hosted model on the server's own machine or network (Ollama, vLLM), the operator sets SLOTLOCK_ASSISTANT_PRIVATE_URLS=on. On Cloudflare Workers the literal check applies and names are not resolved: a Worker's fetch reaches no private network. Changing the provider or the base URL takes the key again (decided in the database write, so racing saves cannot get past it), so the stored key is only ever sent where it was entered for, even on an origin that serves several accounts. Test connection makes one real request and checks the model answers with one correct tool call. The organization's messages, and the bookings and resources the assistant reads, are sent to the provider it configures.
  • The key is stored sealed with AES-256-GCM under a key derived from SLOTLOCK_ASSISTANT_SECRET, bound to the organization, in slotlock.assistant_settings (forced row-level security, erased with the tenant). The dashboard shows only that it is set and its last four characters. Changing the secret makes stored keys unreadable: owners enter them again.
  • Reads run, writes wait. The server runs the tool loop, up to 8 model calls a message, with a fixed list of the agent tools (the reads: resources, free-busy, next free time, slots, pool availability, events; the writes: holds, bookings, pool and bundle bookings, changes and cancellations). A read runs at once through the same operation boundary as an agent's. A write never runs when the model asks: it appears as a proposed change the member approves (or dismisses) with a form, and only then runs, once (its idempotency key is the dashboard's), with the same booking rules, tenant limits and rate limits as an agent, recorded as dashboard-assistant:github:<user id> (shown as "Assistant for @login"). The card leads with the sentence a confirmation would ask (the resource by name, the times in its zone, and for a change or cancellation the event's title, resource and time as it stands) and then shows every argument in full; a change too large to show (16 KB of arguments) is not proposed, and a conversation has at most 4 waiting. A proposal is only checked: the series-write slot and the write's rate token are taken when the approved change runs. A message's reads share a budget of 16 calls and 400 resource-days. API keys, webhooks, booking rules, pools, erasure and coverage are never offered; a tool the model names outside the list is refused.
  • Tool results and booking data are data to the model, never instructions, and the system prompt says so; the tool list does not change with what the model says. A member may send 10 messages a minute; each model call has 30 seconds and a message 90. Conversations are kept per member in slotlock.assistant_conversations for 2 hours after the last message, then deleted: by serve's maintenance, which sweeps every tenant's expired conversations every quarter hour (a tenant only the dashboard serves included), by slotlock maintain, and whenever the tenant's conversations are read (so on Workers too). They are bounded in UTF-8 bytes; New conversation forgets one. On Workers, the assistant's agent calls are capped at 24 a request and by tenant_read and tenant_write of SLOTLOCK_LIMITS in each isolate. A provider's failure is shown as a plain message, never with the key or the provider's answer.
  • It works as plain forms: no script is needed.

Dashboard on Cloudflare Workers

The same dashboard can run as a Cloudflare Worker at an origin of its own (workers/dashboard), while agents keep using the server. It shows the server's MCP and A2A URLs (SLOTLOCK_SERVICE_URL), and reaches the server's database through Hyperdrive, as the role the server serves as.

workers/dashboard/wrangler.jsonc is an example: copy it into your own deployment and replace what it marks as yours. This repository's Dashboard workflow only checks that the Worker bundles. Deploy from your own pipeline with your own Cloudflare token: a token that can deploy Workers can replace this one and read its session secret, which signs in as any tenant.

  1. Create a Hyperdrive configuration for that role, with caching off: the dashboard reads back a sign-in or a form it has just recorded, and a cached sign-out check would keep a copied session working. Check it before each deploy (wrangler hyperdrive get <id>). For a database on a private network, connect it through Workers VPC, on the Cloudflare Tunnel that reaches it. Point the VPC service at a listener that serves only Slotlock's database, such as a PgBouncer with TLS and only that database and role, rather than at the whole Postgres:
    cd workers/dashboard && npm ci
    npx wrangler vpc service create slotlock-postgres --type tcp --tunnel-id <tunnel id> \
      --ipv4 <listener host> --tcp-port <listener port> --app-protocol postgresql
    read -rs SLOTLOCK_DB_PASSWORD   # not on the command line, so not in your shell history
    npx wrangler hyperdrive create slotlock-dashboard --service-id <vpc service id> \
      --database <database> --user <server role> --password "$SLOTLOCK_DB_PASSWORD" --caching-disabled
    
  2. In your copy of the configuration, set:
    • the configuration's id;
    • your hostname (routes);
    • SLOTLOCK_PUBLIC_URL, SLOTLOCK_SERVICE_URL and SLOTLOCK_DASHBOARD_USERS (vars);
    • the three rate limiting bindings, each with a namespace_id unique in your account.
  3. Create the GitHub OAuth app as above, with the callback URL <SLOTLOCK_PUBLIC_URL>/dashboard/callback on the Worker's origin.
  4. Store the secrets on the Worker: npx wrangler secret put for SLOTLOCK_GITHUB_CLIENT_ID, SLOTLOCK_GITHUB_CLIENT_SECRET and SLOTLOCK_SESSION_SECRET, and SLOTLOCK_ASSISTANT_SECRET for the assistant.
  5. Deploy: npm ci --omit=dev at the repository root (the Worker bundles Slotlock's sources), then npx wrangler deploy --config <your configuration> in workers/dashboard.
  • Each request opens its own database client, at most five connections, and closes it when the response is sent; Hyperdrive keeps the pool.

  • Anyone may start a sign-in, and each callback costs a database write and a request to GitHub with the app's client secret. An isolate runs at most eight at once, but Workers run many isolates, so three rate limiting bindings bound them all:

    • SLOTLOCK_CLIENT_RATE_LIMIT counts every dashboard request per client address (an IPv6 client's /64, as slotlock serve counts it);
    • SLOTLOCK_SIGN_IN_RATE_LIMIT counts sign-ins and callbacks per client network (an IPv6 client's /48, a site's allocation, as slotlock serve counts its sign-ins), so a site rotating through its /64s cannot use up the ceiling;
    • SLOTLOCK_SIGN_IN_CEILING_RATE_LIMIT counts them from every client together, set far above one client's limit so a few clients cannot use it up for everyone.

    Their counts are per Cloudflare location. Without either of the first two the Worker answers 503. Without the ceiling, as in a configuration from before it, SLOTLOCK_SIGN_IN_RATE_LIMIT counts every client together, and the Worker logs a warning: add the ceiling before lowering that binding's limit to one client's. The example configuration also removes query strings (a callback's OAuth code) from the URLs Workers Logs keep.

  • With organizations, set SLOTLOCK_DASHBOARD_ORGS on both the Worker and the server: the server takes an organization's keys only while its own list names it, whether or not it serves the dashboard.

  • The server checks a dashboard tenant against SLOTLOCK_DASHBOARD_USERS only when it serves the dashboard itself. With the dashboard on Workers, removing a person from the Worker's list signs them out of the dashboard; to stop their keys, revoke them or erase the tenant.

  • A missing or weak secret makes the Worker answer 503 and log which variable, never its value.

Connect an MCP client

Point any Streamable HTTP client at http://localhost:8080/mcp with the token.

Claude Code:

claude mcp add --transport http slotlock http://localhost:8080/mcp \
  --header "Authorization: Bearer $SLOTLOCK_AUTH_TOKEN"

Cursor (~/.cursor/mcp.json):

{
  "mcpServers": {
    "slotlock": {
      "url": "http://localhost:8080/mcp",
      "headers": { "Authorization": "Bearer ${env:SLOTLOCK_AUTH_TOKEN}" }
    }
  }
}

VS Code (.vscode/mcp.json; it asks for the token once):

{
  "inputs": [
    { "type": "promptString", "id": "slotlock-token", "description": "Slotlock token", "password": true }
  ],
  "servers": {
    "slotlock": {
      "type": "http",
      "url": "http://localhost:8080/mcp",
      "headers": { "Authorization": "Bearer ${input:slotlock-token}" }
    }
  }
}

With SLOTLOCK_CONFIRM_WRITES=all, writes need a client that can show a confirmation (MCP 2026-07-28 with form elicitation); other clients get confirmation_required. Set it to none to let agents book on their own.

Quick start: the next free slot

The availability maths needs no database:

import { expandRules, findNextAvailable } from 'slotlock';

const searchWindow = {
  start: new Date('2026-09-14T00:00:00Z'),
  end: new Date('2026-09-21T00:00:00Z'),
};

const windows = expandRules(
  [{ rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', startMinutes: 9 * 60, durationMinutes: 8 * 60 }],
  searchWindow,
  'Europe/London',
);

export const slot = findNextAvailable({
  windows,
  busy: [{ start: new Date('2026-09-14T09:00:00Z'), end: new Date('2026-09-14T10:00:00Z') }],
  durationMs: 2 * 60 * 60 * 1000,
});
// slot: 2026-09-14 10:00–12:00 UTC, the first two free hours after the busy one

A rule expandRules cannot evaluate adds no availability; it never invents a slot. A search window may be 367 days at most (invalid_window), here and in findNextAvailableFor.

Set up PostgreSQL

RoleOwnsUsed for
Deploymentthe slotlock schemaapplySchema, applyTenantRls, grantApplicationRole, once per release
Applicationnothing (NOSUPERUSER NOBYPASSRLS)all traffic, confined to one tenant by forced row-level security

Create the application role once (CREATE ROLE slotlock_app LOGIN PASSWORD '…' NOSUPERUSER NOBYPASSRLS;), then deploy as the owner:

import { createSlotlockStore } from 'slotlock';
import postgres from 'postgres';

export async function deploySlotlock(deployUrl: string, applicationRole: string): Promise<void> {
  const sql = postgres(deployUrl, { max: 1, onnotice: () => {} });
  try {
    const store = createSlotlockStore(sql);
    await store.applySchema();
    await store.applyTenantRls();
    await store.grantApplicationRole(applicationRole);
  } finally {
    await sql.end();
  }
}
  • applySchema is idempotent and safe to run from several instances at once.
  • grantApplicationRole grants schema usage and DML on Slotlock's tables, and refuses (unsafe_application_role) a role that could get around row-level security. Run it after every applySchema. SECURITY.md lists what it checks.
  • applySchema refuses a slotlock schema another role controls (unsafe_slotlock_schema).
  • Already have a tenant setting such as app.tenant_id? Pass { tenantContextSetting: 'app.tenant_id' } to createSlotlockStore.

Book and read as the application

import { type SlotlockStore, createSlotlockStore } from 'slotlock';
import postgres from 'postgres';

export function openSlotlock(applicationUrl: string) {
  const sql = postgres(applicationUrl, { max: 10 });
  return { sql, store: createSlotlockStore(sql) };
}
export async function bookHandover(store: SlotlockStore, tenantRef: string) {
  return store.withTenant(tenantRef, async (tenant) => {
    const vehicle = await tenant.createResource({
      tenantRef,
      externalRef: 'vehicle-42',
      timezone: 'Europe/London',
    });
    const handover = await tenant.putCalendarEvent({
      tenantRef,
      externalRef: 'handover-BK-42',
      idempotencyKey: 'booking-BK-42-create',
      expectedRevision: 0,
      resourceId: vehicle.id,
      start: new Date('2027-03-29T09:00:00Z'),
      end: new Date('2027-03-29T10:00:00Z'),
      timezone: 'Europe/London',
      summary: 'Vehicle handover',
      organizer: { email: 'fleet@example.com', name: 'Fleet Desk' },
      attendees: [{ email: 'renter@example.com', participationStatus: 'needs_action', rsvp: true }],
      reminders: [{ action: 'display', minutesBeforeStart: 30 }],
    });
    if (!handover.ok) throw new Error(`handover not booked: ${handover.code}`);

    const freeBusy = await tenant.getFreeBusy({
      tenantRef,
      resourceId: vehicle.id,
      window: { start: new Date('2027-03-29T00:00:00Z'), end: new Date('2027-03-30T00:00:00Z') },
    });
    return { vehicle, handover, freeBusy };
  });
}

Run every store method inside withTenant. A call outside it is not refused: it runs with no tenant context, so as the application role forced row-level security shows it no rows.

withTenant validates the tenant, sets the context transaction-locally on the one connection every callback call uses, supports nested savepoints, and restores the previous context. Do not issue a standalone set_config(..., true) through a pool: its transaction ends before the next call. withTenant(tenantRef, callback, { isolation: 'read committed' }) runs the callback in a READ COMMITTED transaction whatever the role's default, and refuses inside a caller's transaction of another level. A store sharing one pool among tenants can bound each tenant's share: createSlotlockStore(sql, { maxTenantTransactions, tenantLockTimeoutMs }) runs at most that many of a tenant's withTenant transactions at once (the rest wait in the process, and leave the line when withTenant's signal aborts), and refuses one that waits longer than that for a lock with lock_timeout, safe to retry. slotlock serve sets tenant_transactions (2) and 5,000.

createResource({ ..., maxTenantResources }) refuses a new resource (resource_limit_reached) once the tenant has that many (1-100,000); an existing externalRef is still updated. Capped creates for one tenant take a database lock and count under READ COMMITTED, so racing ones stop at the cap on any number of servers; inside a REPEATABLE READ or SERIALIZABLE transaction they refuse (invalid_transaction_isolation). Uncapped creates take no lock.

setResourceAvailability({ tenantRef, id, rules }) gives a resource its own bookable hours (null uses the default, [] closes it); setTenantAvailability({ tenantRef, rules }) sets every resource of a tenant. createSlotlockStoreAgentBackend answers from a resource's own hours and calls availabilityRules only for resources without them.

Waitlists: joinWaitlist({ tenantRef, resourceId | pool, start, end, holderRef, offerTtlMs }) waits for a window that is taken now. When it comes free (a cancel, a released or expired hold, a no-show), offerWaitlist gives it to the entries in the order they joined: the first whose window is free, on its resource or the first free member of its pool, gets a hold for its holder, and the feed says so (waitlist.offered, with hold_id). Confirm the hold to book it; let it go and the next is offered. Cancels, releases and moves, whether an agent (MCP, A2A) or the application API makes them, offer at once; maintenance offers what lapses on its own (an expired hold, a no-show) and expires entries whose window has started (waitlist.expired). With checkInMinutes in a resource's booking rules, maintenance releases a booking made since the rule was set that nobody checked in that long after its start, or after it was made for a walk-in (reservation.no_show), and the waitlist is offered its time. Check-in opens an hour before the start; moving a booking to another start clears it. Leaving the waitlist lets an offer not yet confirmed go with it.

Calendar tools speak the standards: emitVAvailability({ rules, timezone, uid }) writes rules as an iCalendar VAVAILABILITY (RFC 7953) and parseVAvailability(text) reads one back as { timezone, rules }; toJSCalendarEvent(event) answers a calendar event as a JSCalendar Event (RFC 8984). The API serves them at /v1/resources/{resource_id}/availability/ical and /v1/tenant/events/{event_id}/jscalendar.

Writing rules:

  • Create with expectedRevision: 0; update with the revision you last read and a new idempotency key. Replaying the same command and key returns the original result (idempotent: true); reusing a key with a different payload returns idempotency_conflict.
  • A conflicting opaque occurrence returns { ok: false, code: 'overlap' }. Transparent events never block.
  • Cancelling leaves a tombstone, so a delayed create cannot resurrect the event; use a new external reference for a genuinely new event.
  • listCalendarEvents and listResources return at most 1,000 rows and page with keyset cursors.

Holds, external bookings and the feed

export async function holdBookAndFollow(store: SlotlockStore, tenantRef: string, resourceId: string) {
  // Inside withTenant: outside it the application role's row-level security shows no rows.
  const booked = await store.withTenant(tenantRef, async (tenant) => {
    // Hold a window for ten minutes while the customer pays, then turn it into a booking.
    const held = await tenant.acquireHold({
      tenantRef,
      resourceId,
      start: new Date('2027-04-05T09:00:00Z'),
      end: new Date('2027-04-05T10:00:00Z'),
      ttlMs: 10 * 60_000,
    });
    if (!held.ok) throw new Error(`not held: ${held.code}`);
    const confirmed = await tenant.confirmHold(held.hold.id, { tenantRef });
    if (!confirmed.ok) throw new Error(`hold not confirmed: ${confirmed.code}`);

    // A booking your own system already has, by its reference there: revision 1 creates it.
    const imported = await tenant.createExternalReservation({
      tenantRef,
      externalRef: 'BK-43',
      resourceId,
      start: new Date('2027-04-05T11:00:00Z'),
      end: new Date('2027-04-05T12:00:00Z'),
      bufferAfterMs: 15 * 60_000,
      revision: 1,
    });
    if (!imported.ok) throw new Error(`BK-43 not booked: ${imported.code}`);
    return { booking: confirmed.reservation, imported: imported.reservation };
  });

  // What changed, oldest first, once committed: so in a withTenant of its own. Poll again with
  // `after: feed.next`.
  const feed = await store.withTenant(tenantRef, (tenant) =>
    tenant.listChanges({ tenantRef, limit: 100 }),
  );
  if (!feed.ok) throw new Error(`feed not read: ${feed.code}`);
  return { ...booked, feed };
}

Recurring events

Documentation truncated — see the full README on GitHub.

Reviews

No reviews yet

Be the first to review this server!