Skip to content
Conduit

mcp server

hourmere

Open-source, agent-native scheduling: an AGPL Calendly alternative with a REST API and an MCP server. Self-host it; Hourmere Cloud is in invitation-only preview.

hourmere/hourmerecommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

This project does not publish an install method we can turn into a config. Follow the README instead.

README

synced Oct 28, 2026

Hourmere

Open-source scheduling for people and AI agents. Self-host from source; Hourmere Cloud is coming.

Early development. Nothing here is ready to use yet.

Features

Repository layout

PathWhat it is
apps/serverAPI server (Hono). Serves the built web app in production.
apps/webWeb app (React + Vite). Talks to the server only through the public API.
apps/siteWebsite for hourmere.com (Astro + Starlight): homepage, legal pages, docs. Static; not part of the Docker image.
packages/coreScheduling logic with no I/O or framework code, such as the availability engine and its opt-in explanations.
packages/dbDatabase access (Kysely) and SQL migrations in packages/db/migrations.
packages/schedulingService layer shared by the REST API and the MCP server: reads and writes the database, enforces permissions, throws DomainError.
packages/authSign-in (Better Auth: email links, Google, Microsoft), sessions, organizations, API keys.
packages/calendarsSeparate Google Calendar OAuth, encrypted credentials, incremental busy-time sync and booking writeback. See Google Calendar.
packages/conferencesIndependent Zoom connections and single-host automatic meetings; see automatic Zoom, separate from sign-in.
packages/mailOutgoing email: SMTP, or the server log when SMTP is not configured; booking email templates and calendar invitations.
packages/jobsBackground jobs on Postgres (pg-boss): typed job definitions, enqueueing inside a transaction, workers.
packages/webhooksOptional signed organization booking webhooks, transactional delivery and bounded retries. See webhooks.
packages/notificationsDurable booking and sign-in email delivery, retries and the encrypted credential outbox. See notifications.
tools/api-typesGenerates the web app's API types from the server's OpenAPI document (npm run api:types).
tools/cliAPI-key command-line client using the existing MCP tools (npm run --silent cli -- --help). See CLI.

CalDAV is currently a design and credential/discovery foundation only. iCloud, Fastmail and Nextcloud connections, busy sync and booking writes are not yet available in the app, REST, MCP or CLI; no real-account acceptance has been completed.

Development

Requires Node.js 26 and Docker.

cp .env.example .env   # set BETTER_AUTH_SECRET, e.g. `openssl rand -base64 32`
npm install
npm run db:up      # Postgres on localhost:5432
npm run dev        # API on :3000, web app on http://localhost:5173

Without SMTP_URL, sign-in links are printed in the npm run dev output instead of being emailed. In development anyone may sign up unless SIGNUP or SIGNUP_ALLOWED_DOMAINS is set (see Self-hosting).

Open the app at http://localhost:5173. Vite serves it and proxies /api/ and /sign-in/confirm (the server's page for emailed sign-in links) to the API server (on PORT from the environment or .env, default 3000), so the browser talks to one origin, as in production. To run the two separately, start the API with sign-in links and cookies pointing at Vite (and open sign-up, as npm run dev does), then Vite:

PUBLIC_URL=http://localhost:5173 SIGNUP=open npm run dev --workspace @hourmere/server
npm run dev --workspace @hourmere/web

Signed-in pages live under /app (/app/event-types, /app/availability, /app/bookings, /app/api-keys, /app/settings); /sign-in stays at the top level, and the rest of the root is for public booking pages (/{handle}/{slug} for personal types and /team/{teamSlug}/{slug} for individual, collective and activated round-robin team types). After signing in, people only return to pages under /app.

One-time booking links open /book/{token}. They offer the usual booking form, including editable invitee prefill, and are consumed only when a booking commits. Every unavailable link shows the same neutral page. These links cannot be embedded; their pages and API responses send no referrer and are not cached. Create, list and revoke them in the event editor, REST, MCP or CLI; the full URL is shown only in the creation response. See one-time booking links for permissions, replay handling and limits.

Saving an event type opens its saved version's View and share actions. Open the public booking page or copy its full link there; unsaved address changes cannot be copied as a published link. When setup or enablement needs attention, the panel links to the relevant settings. Round-robin pools still require a separate explicit activation after saving.

When the operator enables SLOT_SNIPPETS_ENABLED (off by default), saved event views let personal owners and team owners/admins pick 1–10 open alternatives for one meeting length and copy a plain-text or rich-text email snippet. The picker checks ordinary public availability again before every copy; no time is reserved. Hidden event links can be forwarded. Enable this setting only after public direct-slot booking pages are deployed on the same instance. Email language and display zone affect labels only; the booking page keeps the invitee’s own preferences. Agents can generate the same text/HTML with POST /api/v1/open-time-snippets, MCP generate_open_time_snippet or CLI open-time-snippet --json. A read key suffices with owner/admin permission; see the agent guide for bounded ranges, examples and taken-time fallback.

The web app only uses the public API (/api/v1/* and Better Auth's /api/auth/*). Its types for /api/v1 are generated from the server's OpenAPI document: after changing a route, run npm run api:types (no database needed) and commit apps/web/src/api/schema.d.ts; CI fails if it is out of date.

Other commands: npm run lint, npm run typecheck, npm test, npm run build, npm run db:migrate, npm run db:down, npm run api:types.

The website runs on its own: npm run dev --workspace @hourmere/site (http://localhost:4321). npm run build writes it to apps/site/dist. A small Cloudflare Worker handles the Hourmere Cloud waitlist and serves the static assets on hourmere.com and www.hourmere.com (config in apps/site/wrangler.jsonc). The D1 database already exists. See website operations for local D1/form testing and the migration the lead must apply before running npm run deploy --workspace @hourmere/site in the Cobaltmere Cloudflare account.

The privacy policy's effective and last-updated dates and the terms' effective date share LEGAL_PUBLICATION_DATE in apps/site/src/data/legal.ts, currently 2026-10-22. Keep that date on later deployments, including documentation and homepage changes; change it only when publishing revised legal text. A placeholder or invalid date stops the site build. Environment variables cannot override the source date. CI retains a fixture-date substitution for a placeholder source date; with the current committed date, that substitution makes no change. Its build steps restore the source with an exit trap and delete the generated site output. Never deploy fixture output or commit a fixture date.

Self-hosting

The Starlight documentation includes a self-hosting guide, configuration reference, and REST / MCP quickstarts.

Requires Git, OpenSSL, Docker Engine and Docker Compose 2.24.4 or newer. Point your hostname at the host with an A record only and allow TCP 80/443 and UDP 443 (plus SSH) before using automatic HTTPS:

git clone https://github.com/hourmere/hourmere.git
cd hourmere
./scripts/setup.sh --source
./scripts/compose-up.sh -d --build --wait
curl --retry 20 --retry-delay 3 --retry-all-errors --fail https://hourmere.example.com/api/health

Accept proxy, enter your HTTPS origin (replace hourmere.example.com with your hostname), and configure SMTP and its sender. Setup generates separate secrets directly into a mode 600 .env, refuses to overwrite an existing file, and selects the optional Caddy override. Caddy obtains/renews TLS certificates; only TCP 80/443 and UDP 443 are published, and the app trusts only the proxy's dedicated address for forwarded client IPs. If its network overlaps an existing network or another instance, change PROXY_SUBNET, PROXY_IP and PROXY_DYNAMIC_RANGE together in .env before starting. No prebuilt public image is published yet; Compose builds the checked-out source. Application and job migrations run automatically (AUTO_MIGRATE=true). Open /sign-in and claim the first account immediately; SIGNUP=first-user restricts later registration as described below.

Use ./scripts/compose-up.sh for starts and upgrades. It hashes the current host Caddyfile on each run and makes Compose recreate only the proxy when that content changes, including operator edits. This briefly interrupts HTTPS while Caddy restarts. Keep editing the root Caddyfile; retain its global log filter that removes request URIs and request/response headers. Review upstream changes alongside customizations before starting the new revision. Direct docker compose up bypasses this digest update.

Select plain for an existing proxy or an explicitly acknowledged private loopback trial. It keeps the app on 127.0.0.1:3000 and PostgreSQL unpublished. For your own proxy, set its exact address as the app sees it in TRUSTED_PROXIES and preserve Host/forwarded headers; see the self-hosting guide for the complete TLS, backup/restore, upgrade and host-hardening procedures. The default app also runs background jobs; to split them after setup, run:

(
  unset COMPOSE_FILE &&
  ./scripts/enable-worker.sh &&
  compose_services=$(docker compose config --services) &&
  { printf '%s\n' "$compose_services" | grep -qx worker ||
    { echo "Worker service is missing; check COMPOSE_FILE before continuing."; exit 1; }; } &&
  { printf '%s\n' "$compose_services" | grep -qx proxy ||
    { echo "Proxy service is missing; check COMPOSE_FILE before continuing."; exit 1; }; } &&
  ./scripts/compose-up.sh -d --build --wait
)

For plain mode, remove both lines of the proxy verification (the grep -qx proxy line and its following failure-message line) before pasting the block.

The helper safely updates COMPOSE_FILE in the private .env, preserving the proxy selection. The tracked overlay automatically selects web for the app and worker for the separate worker, using the same image and secrets. The block unsets a shell-exported COMPOSE_FILE only inside its subshell; unset it separately before standalone Compose commands so they read .env too. The .env backup preserves this choice, and upgrades need no tracked Compose edits. Give containers at least 35 seconds to stop.

Before upgrading, take a full backup, including the host Caddyfile with any operator customizations, and record the current branch name or tag alongside the backup's saved commit. A checkout-only rollback with git checkout <previous branch or tag> is safe if a guard, docker compose pull or docker compose stop fails before the new-version docker compose up command runs. Once that up is attempted, follow Back up and restore, even if it fails: a new app or worker may already have applied migrations; old code against the new schema is unsafe. In the supplied Compose files, AUTO_MIGRATE=true runs application and pg-boss migrations at process startup for every role (all, web and worker), not during pull or stop; there is no separate migrate service. A separately started new-version worker, npm run db:migrate or a custom migration job can migrate earlier; if any has started, use backup/restore even before this Compose up. For a failed main-tracking pull, the backup's saved commit identifies the exact pre-upgrade revision: git pull advances main, so its branch name alone does not undo the update. If rollback leaves a pre-1.0 main-tracking checkout detached, run git switch main before the next upgrade so git pull --ff-only works.

Git does not undo enable-worker.sh's private .env edit. Before checking out the prior revision, restore only the pre-upgrade COMPOSE_FILE assignment from the saved private .env (remove the assignment if it was absent), preserving the proxy/custom overlays and other current settings. See the guarded upgrade procedures.

For a pre-start failure, if the app or worker was already stopped (including a partial stop failure), bring the old version back with the block below after restoring that selection. Replace PREVIOUS_BRANCH_OR_TAG with the recorded name. If no service was stopped, run the same block: checkout can replace the host Caddyfile while the running proxy still holds its old inode. The block uses the existing old application image; do not add --build or pull an application image during this recovery.

(
  git checkout "PREVIOUS_BRANCH_OR_TAG" &&
  unset COMPOSE_FILE &&
  compose_services=$(docker compose config --services) &&
  docker compose up -d --wait app db &&
  if printf '%s\n' "$compose_services" | grep -qx worker; then
    docker compose up -d --wait --no-deps worker
  fi &&
  docker compose up -d --wait --force-recreate --no-deps proxy
)

Starting app/database and the selected worker explicitly leaves the proxy untouched until the final command, avoiding a second HTTPS interruption. Named certificate and data volumes are preserved. The final command also works when the previous revision lacks the helper; it briefly restarts the proxy so it binds the prior revision's actual Caddyfile. For plain mode, omit that command and remove the preceding &&. Full restore must restore the backed-up host Caddyfile before creating the proxy, as shown in the self-hosting guide.

When upgrading from an older release, compare sha256sum Caddyfile with docker compose exec -T proxy sha256sum /etc/caddy/Caddyfile (use shasum -a 256 Caddyfile on macOS). If the hashes differ, run docker compose up -d --wait --force-recreate --no-deps proxy, then compare again.

The application image retains small public attribution and booking-confirmation links to the hosted service. HOSTED_SIGNUP_URL sets their destination (default https://hourmere.com); there is no setting to hide them.

Set PRIVACY_POLICY_URL and TERMS_URL to your operator's absolute HTTP(S) legal-page URLs to display them in the app. Both default to empty, which hides the corresponding link; self-hosted installations do not inherit Hourmere Cloud's policies. The hosted instance must explicitly set https://hourmere.com/privacy and https://hourmere.com/terms.

Team owners and admins can configure collective meetings with two to eight required hosts, or round-robin pools with one to eight hosts and explicit activation.

Website widgets support inline and popup booking; see embedding for the explicit parent-origin allow list, integration snippets and privacy contract.

Responses carry security headers: X-Content-Type-Options: nosniff, X-Frame-Options: DENY (except explicitly approved dedicated embed pages), a Referrer-Policy, and on pages a Content-Security-Policy that lets only Hourmere's own scripts, styles and requests run. When PUBLIC_URL is https, browsers are also told to use only https for that host, for a year (HSTS_MAX_AGE). Clients that send too slowly are disconnected: 20 s to send a request's headers and 60 s for the whole request (HTTP_HEADERS_TIMEOUT, HTTP_REQUEST_TIMEOUT). Idle connections stay open for 125 s (HTTP_KEEP_ALIVE_TIMEOUT): keep it above your reverse proxy's idle timeout for connections to the app, or the proxy may reuse a connection the app has just closed and answer 502. The default is above the usual ones: nginx's upstream keepalive and AWS ALB 60 s, Traefik 90 s, Caddy 120 s. CSV exports reserve at most two database connections per server process by default. BOOKING_EXPORT_MAX_CONCURRENT accepts 1–8 (invalid values use 2); keep this below the database pool capacity, with headroom for ordinary traffic. Excess requests fail before acquiring a connection with HTTP 409 export_busy. Each stream closes after 30 seconds without reader progress, or after five minutes overall. These limits apply in addition to the one-export-per-organization database lease.

People sign in with a link sent by email (no passwords), or with Google or Microsoft once their client ids are set in .env. Accounts are only created for email addresses the provider confirms: for Microsoft, add the optional ID token claims verified_primary_email and verified_secondary_email in the Entra app registration (Token configuration), or Microsoft users get EMAIL_NOT_VERIFIED and have to use an email link. Until SMTP_URL and MAIL_FROM are set, the server prints each sign-in link to its log (docker compose logs app), so you can sign in right after the first start. Anyone who can read that log can sign in as anyone: configure SMTP before inviting anyone else, and always on a hosted or internet-facing instance. The setup script requires SMTP for public deployment; empty SMTP is accepted only for an explicitly acknowledged private loopback trial.

SIGNUP decides who may create an account; existing users can always sign in. With first-user (the default), the first person to sign in gets an account, and after that only addresses in SIGNUP_ALLOWED_DOMAINS (comma-separated email domains, e.g. example.com,example.org; exact match, so list subdomains separately). So sign in right after the first start: the first account should be yours. closed allows those domains and the invitation exception below. With an empty domain list, only invitations from the first user admit new accounts, so bootstrap an empty instance in first-user mode or through an administrator before using closed. open lets anyone who can reach the instance sign up. A pending, unexpired team invitation allows its exact email address in either restricted mode only if the inviter's current email domain is allowed or the inviter is the instance's first user (the earliest remaining auth user by creation time, with user id breaking ties). The original inviter must also still belong to the invitation's team; demotion while remaining a member preserves this eligibility. A void invitation grants no account or sign-in email to an otherwise ineligible address. Invitation-based registration is non-transitive: an invitee outside the allowed domains cannot admit another address by creating a team and inviting it. Team creation and invitation acceptance grant no registration authority. These rules apply to existing invitations too, using the current domain allowlist. Sign-up still requires proving that email address; joining the team requires accepting the invitation separately. Expired, canceled or already accepted invitations do not allow new accounts. A new address that may not sign up gets no sign-in email, and the request is answered the same way as for any other address. Every request queues an encrypted, short-lived email job before eligibility is checked by a worker; the request does not wait for SMTP or check whether the address has an account. Workers make at most four delivery attempts and discard expired links. Request a new link if delivery fails. In web-only mode, a separate worker is required for sign-in emails as well as booking notifications. Failures appear in the log without the recipient or link.

Team invitation links open /app/join?invitationId=<uuid>. Sign in with the exact email address that received the invitation; email-link and enabled social sign-in return to that page. Signing in does not join the team: review the team and signed-in account, then choose Join team. After joining, the event types page confirms the team name and links to your default availability. Dismiss this one-time notice, navigate away or reload to clear it. If you used a different account, Change account signs you out while keeping the invitation for your next sign-in. Invitation details remain restricted to the verified recipient. An existing member can open the team without accepting again. If the original inviter leaves or is removed from the team, their pending invitations are no longer valid. Ask a current team owner/admin for a new invitation; those old links cannot be accepted or resent, and recipient invitation discovery omits them. An inviter's demotion while remaining a member does not invalidate links.

The signed-in header's space menu switches between Personal and your member organizations and shows your role in the current space. Switching asks before discarding unsaved changes and starts the page over in the selected space. A failed switch keeps the current page; returning to personal after team access or active context changes shows a notice. Switching the web app does not change an API key's organization or an existing MCP OAuth grant.

In a team space, Settings lists joined members by name and role with pagination. Owners and admins also see a separate paged section of pending invitations and their mail submission status. Mail server acceptance does not confirm inbox receipt; pending invitations never count as members or hosts. Owners/admins can resend or cancel each pending invitation, and select expired invitations to atomically cancel the old invitation and send a new invitation with a new link under the acting admin, without a duplicate pending row. Cancellation never removes a member who has already joined. Invitations from someone who no longer belongs to the team show No longer valid; cancel the old invitation and use the invite form to send a fresh invitation in your own name. The expired-row replacement action cannot reuse these invitations. Owners can promote members to admins or return admins to member access after reviewing the permission change. Only owners manage admins; owner roles cannot be changed, and ownership transfer is not available yet. A team must always retain at least one owner.

Member rows show shared configuration and cached calendar health checks, without promising available times. Owners and admins see only coarse states for other members, including whether they have selected a conflict calendar or connected a conference account; ordinary members see their own readiness. Providers, calendar and connection IDs, account counts, private calendar names, account emails, events and busy times are excluded. Your own row links to schedule, calendar and conference settings and refreshes when you return. Your personal default schedule is reused across every team you belong to; an external calendar is optional for basic bookings.

The team event host editor shows the same coarse checks for selected hosts. Your own setup links open your settings; other hosts' setup links open the members page with guidance to ask the member to make private changes. Owners and admins can open a member's hosted team event types from that member's row, including dormant round-robin pool candidates.

The database enforces the sign-up rules too, using settings the app passes in the options startup parameter when it connects (search_path and hourmere.*), and it needs read committed isolation (Postgres's default) or serializable. Connect the app to Postgres directly, as the compose file does; connection poolers such as PgBouncer are not supported, since they do not pass custom startup settings like hourmere.* on to Postgres. The app reads the settings back at startup and refuses to start if they are missing.

That check looks at one connection, once, at startup: a pooler slipped in later at the same address would turn the database's check off without notice (the app's own check still applies). It also means the app needs the database at startup even with AUTO_MIGRATE=false; if the database is down, the app exits and compose restarts it.

Sign-in links open a page with a "Sign in to Hourmere" button, and only pressing it signs in: email security scanners that open every link (such as Microsoft Safe Links) would otherwise use up the one-time link before the person clicks it.

Agents use API keys (hm_…) sent as Authorization: Bearer <key>. Create them on the web app's API keys page (/app/api-keys), or with Better Auth's endpoints while signed in (POST /api/auth/api-key/create, GET /api/auth/api-key/list, POST /api/auth/api-key/delete). The API is described at /api/v1/openapi.json; its conventions are in API conventions. The same keys connect MCP clients such as Claude Code to the remote MCP server at /mcp; see MCP. Choose Bookings (the web default) for an AI booking agent, Read for analytics/reporting, or Full account access when you need configuration writes. Existing keys retain full access. A key's scope cannot change: create a replacement, verify it, then revoke the old key. See API key scopes for the generated operation table and the required stop-before-upgrade procedure.

For terminal scripts, the dependency-free CLI supports configuration discovery, slots, booking, host history, rescheduling and cancellation with the same API key.

Personal and single-host team event types can enable a booking waitlist in the event editor. Invitees join for an unavailable date or period and confirm their email before receiving availability notifications. Eligible invitees are notified together, subject to mail limits; nothing is reserved and the first successful ordinary booking wins. Waitlists use the default meeting length. Hosts can view and remove their event's entries; team contacts are restricted to owners/admins. REST, MCP and CLI provide the same settings, list and removal operations.

Configure event-type reminders and follow-ups through REST, MCP set_event_type_notifications or CLI set-event-notifications with full scope. Follow-up rules may carry bounded plain-text host notes. These are sent from the instance's mail domain to unverified invitee addresses; operators can disable writes and sending of notes with FOLLOW_UP_HOST_NOTES_ENABLED=false (self-hosting default: true). Configure web and worker identically. Hourmere Cloud must explicitly set this to false until reviewed host-text abuse controls are implemented. See notification settings and host-note policy.

Organization owners can view first-party booking activity at /app/analytics: daily creations, reschedules and actual cancellations, without visitor tracking or attendee details.

Integrations

Optional independent Zoom account connections provide a separate minimum-scope identity foundation. Explicit opt-in automatic Zoom supports personal and single-host team bookings with durable reconciliation, same-meeting rescheduling and cancellation, ready-only invitations and an optional ordinary Google calendar copy. Zoom is not a sign-in provider. See the Zoom account guide before configuring a real client.

Optional automatic Google Meet creates one conference per explicitly opted-in individual, collective or round-robin booking, with durable pending/failure state and final invitations held until its link is ready.

Known limitations

Organization owners and team admins can store a logo and primary colour through settings, REST, MCP and CLI. Branding settings include safe image uploads and automatic readable button-text selection; public pages and emails do not apply the stored theme yet.

See the consolidated limitations and linked operating guides before deploying:

  • Booking limits apply per event type and actual host, across all meeting lengths. Organization-wide quotas and rolling windows are unavailable. Lowering a limit does not cancel existing bookings.
  • Public booking and sign-in pages have no CAPTCHA; invitee/guest addresses remain unverified. Email abuse limits apply per instance/database; separate instances do not share budgets. Per-IP request limits remain in process memory, except the additional durable booking-waitlist admission limits.
  • Microsoft Calendar lists calendars only; no busy-time sync or event writes. Microsoft sign-in and calendar connections have not had real-account acceptance.
  • Automatic Zoom supports personal event types and individual team event types with one actual host; collective and round-robin event types are unsupported.
  • Meeting location choices are configured through REST, MCP or CLI; the editor does not yet offer a multiple-location editor. Rescheduling preserves the choice.
  • Multiple meeting lengths share one set of hosts, location and booking rules; per-length overrides are unavailable. Web rescheduling keeps the original length; changing it requires REST, MCP or CLI.
  • Calendar RSVP replies are not processed; declining in a calendar does not cancel a booking.
  • PostgreSQL 18+ is required; PgBouncer is unsupported.
  • Hourmere must be served at the root of its own host name (no path prefix) in every deployment. Bundled Caddy additionally requires ports 80/443, an HTTPS host name without a port, and an IPv4-only ingress network.
  • Rate limiting uses in-process memory at the transport layer; multiple web replicas multiply the limits. Booking waitlists additionally enforce durable mailbox, event and trusted-IP admission budgets in PostgreSQL across replicas. Run a single web process; a separate worker is supported.
  • Public booking pages and emails support English, Simplified Chinese, German, French, Spanish and Brazilian Portuguese. Account and sign-in interfaces support English and Simplified Chinese. Missing translations fall back to English; dates follow the active locale.
  • No automatic schema downgrade; some migrations require stopping all old app and worker processes. Review the migration notes.

Contributing

Bug reports and ideas are welcome in GitHub Issues, as are pull requests. CONTRIBUTING.md covers setup, checks, conventions and the DCO sign-off; everyone follows the code of conduct.

Security

Report vulnerabilities privately through the repository's Security tab or to security@hourmere.com, not in a public issue. See SECURITY.md.

License

AGPL-3.0

↑↓ navigate openesc close