Open-source group scheduling on the AT Protocol. An alternative to LettuceMeet where your polls live in your Bluesky account — not a centralized database.
Live at avails.citizeninfra.org
avails.zhgnv.com permanently redirects here as of 2026-08-04 (#150). Old links keep working; sign-in and the MCP endpoint live on the new host only.
- Sign in with Bluesky — your poll is stored as an ATProto record in your PDS
- Pick dates and times — single-page creation, no multi-step wizard
- Share the link — participants paint their availability on a drag-to-select grid. No account needed.
- Pick a time — the heatmap shows where everyone overlaps. Finalize and calendar invites (.ics) are sent automatically.
Most scheduling tools store your data on their servers. If the company shuts down, pivots, or gets acquired — your polls, your history, your community's scheduling patterns disappear with it.
Avails is built on the AT Protocol (the open protocol behind Bluesky). This means:
- You own your data. When you create a poll, it's stored as a record in your Personal Data Server (PDS) — the same place your Bluesky posts live. It's your data, on your account, under your control.
- No lock-in. If avails disappears tomorrow, your polls are still in your PDS. Anyone can build another client that reads the same data using the same open lexicon schema.
- Portable identity. You sign in with your Bluesky handle. No new account, no new password. Your identity works across every ATProto app.
- Interoperable by design. The poll and response formats are defined as Lexicons — open schemas that any developer can use. Other scheduling tools, community dashboards, or bots can read and create the same records.
You don't need to know or care about any of this to use avails. It works like any other scheduling tool. But under the hood, your data isn't trapped in someone else's database.
- Drag-to-paint availability grid — click and drag to mark when you're free, like LettuceMeet and When2Meet
- No account to respond — participants just open the link and paint. Optional email for calendar invites.
- Calendar invites — when a time is picked, everyone who left their email gets an
.icsinvite that shows up in Google Calendar, Outlook, Apple Calendar - Google Calendar overlay — connect your calendar to see busy times on the grid before marking availability
- Community-scoped — tag polls to a community. They show up in My Community and Dear Neighbors dashboards.
- Creator notifications — get an email when enough people have responded
- Schedule on the grid — creator selects a time block directly on the heatmap, seeing everyone's availability while choosing
- Edit and delete — participants can edit or delete their availability after submitting
- Timezone support — grid auto-converts to each viewer's local timezone. Creator in Budapest, participant in New York — everyone sees their own local times
- Mobile-native grid — touch drag to paint availability, tap any slot to see who's available. Responsive layout with pagination for many dates.
Avails has a built-in MCP endpoint — connect it to Claude Code, Cursor, or any MCP-compatible AI tool and manage polls from your terminal.
claude mcp add -s local -t http avails https://avails.citizeninfra.org/mcpThen just ask:
- "Create a poll for next week, 3-7pm, for the team retro"
- "Who's available on Thursday?"
- "Schedule the meeting at the best time and send invites"
- "Share the poll to the events topic in our Telegram group"
You authenticate with your own Bluesky account — polls are created under your identity, stored in your PDS. No API keys, no shared accounts.
Available tools: create_poll, get_poll, list_polls, list_my_polls, update_poll, delete_poll, schedule, share_poll, publish_to_community_feed, list_communities, evaluate_availability_overlap, schedule_call
| Layer | Tech |
|---|---|
| Client | React 19, Vite, Tailwind CSS, shadcn/ui, Luxon |
| Server | Express 4, Node.js (ES modules) |
| ATProto | @atproto/lex (custom lexicons + Client API), @atproto/oauth-client-node |
| MCP | Embedded JSON-RPC endpoint with ATProto OAuth + PKCE S256 |
| Resend + ical-generator | |
| Timezone | Luxon (DST-safe conversion between creator and viewer timezones) |
| Deploy | Railway |
# Server (API + ATProto OAuth)
cd server && npm install && npm run dev
# Client (React SPA)
cd client && npm install && npm run devThe client dev server proxies /api requests to the Express server on port 3000.
Avails uses Open Index (0.2.0-draft.1) from Citizen-Infra/design-system, pinned through HTTPS Git to 6df5682711d11d1dde20453d0e06a78aca48b34f in the client manifest and lockfile. Public Sans and canonical yellow/blue roles feed the existing React/shadcn/Radix controls; app-specific phone/grid density is documented in DESIGN.md. There is no automatic upgrade or npm publication dependency.
Use the repo's Node 22 line (.node-version); the shared package requires Node 22.12 or newer. CI reads that file and runs client npm ci, then npm test, then npm run build. Unit tests validate source/package/notice invariants without requiring dist/. The build's automatic postbuild gate checks both emitted Public Sans binaries and the MIT/OFL notices byte-for-byte against the installed pin. Existing server gates remain separate.
main.jsx imports package fonts.css directly so Vite rebases its font URLs; globals.css imports the canonical tokens and aliases shadcn roles. Keep this font entry path. After an explicitly authorized pin change, npm run sync-foundation-assets in client/ refreshes the public MIT/OFL notice copies and resolves the existing favicon geometry's colors from that pin. App AGPL, shared-code MIT and font OFL licences remain distinct; the package is not relicensed as the app. Visual-check fixtures must stay synthetic and locally intercepted as described in PRODUCT.md.
The homepage and poll links share og-image-open-index.png, a 1200×630 Open Index cover. It contains only the existing mark, landing headline and a fixed illustrative grid—not participant names, response counts or availability. Poll links still inject their own escaped title and authored description (or date/time fallback) into document, OG and Twitter metadata. Unavailable polls retain the generic preview. The image alt text describes the shared artwork, not a particular poll.
The editable source is client/scripts/social-preview.html. It uses the pinned foundation's local Public Sans/tokens, the favicon and the app's slotOpacity function; it reads no API or poll data. The source page is development-only, not an application route or a Vite build entry. Production ships the PNG, not the source page.
To regenerate after an artwork change:
- Start the client with
npm run devinclient/and open/scripts/social-preview.htmlin Chromium. Use a 1200×630 viewport, device scale factor 1 and 100% zoom; wait fordocument.fonts.readyand all 56.slotelements. - Capture the
.social-previewelement as PNG toclient/public/og-image-open-index.png(Chromium DevTools' Capture node screenshot, or Playwright'slocator('.social-preview').screenshot()). Check the actual output is 1200×630. Preserve an original capture while adding metadata. - Embed truthful PNG text provenance with the source path/hash, foundation pin and renderer settings. With Impeccable installed, use
impeccable embed-prompt <image> --prompt "<render provenance>"; this is code-rendered artwork, not AI-generated imagery. Keep private design briefs out of shipping files and verify the image pixels are unchanged by metadata embedding. - Inspect the image at 1200, 600 and 360px wide. When checking a newly created public file through Vite, restart the dev server after export or intercept the saved PNG bytes: the dev server's public-file cache can otherwise briefly return HTML instead of the new image.
- Run client/server tests and the client build below. Stop the temporary browser/dev server afterward. Never use a real poll screenshot or live PDS resolution as a preview fixture.
The approved target is Avails’ US PostHog project 693844. analyticsConfig.js holds its public ingestion token, US ingest host and an explicit enabled switch. This is intentionally public browser configuration, not a secret or personal API key. Set enabled: false and deploy to disable the integration globally; never substitute a private API key. No PostHog SDK, script, feature-flag request, replay or autocapture is loaded.
analytics.js uses PostHog’s single-event capture API. It makes no PostHog request and creates no analytics identifier until explicit consent. The document-flow consent panel offers equal-weight allow/off controls and a persistent change-preference entry without covering the poll grid. Consent is stored as avails.analytics-consent.v1 in local storage; the random, account-unlinked identifier exists only in page memory after opt-in. It survives client-side navigation but changes on reload; no analytics identifier is stored in cookies or browser storage. Blocked consent storage falls back to the current tab’s memory. Withdrawal, including withdrawal in another tab, cancels pending requests and discards the identifier. Already-delivered events cannot be recalled by that control.
Keep these lifecycle protections when extending analytics: never recover an identifier from sessionStorage, which browsers may copy into a newly opened/duplicated tab; use each storage event’s newValue to honor withdrawal transitions even when a later change has already reached local storage. Reading only the final stored value can silently skip revocation. Both cases have regression tests.
Only these events are supported:
| Event | When | Custom properties |
|---|---|---|
page_viewed |
After consent, on a change of broad page category; duplicate effects are suppressed | page: home, poll, privacy, about, terms, standing, call or other |
poll_created |
Successful browser poll creation | None |
response_submitted |
Successful new browser response; edits do not count again | None |
meeting_finalized |
Successful browser meeting finalization | None |
Payloads are constructed from an allowlist, never copied from API input/results. No poll IDs/content, participant/account identifiers, emails, names, availability, calendar data, raw URLs/referrers, exception text or browser fingerprint properties are added. Requests omit cookies and referrer headers, use a placeholder $ip, disable GeoIP/person-profile processing, time out after five seconds and never retry or backfill. The provider still receives network-level information on a direct connection; do not call this complete anonymity. A capture acknowledgement is not independent proof of dashboard visibility or provider-side settings.
These are consenting page-session counts, not all traffic, account retention, per-poll conversion or cross-participant funnels. Reloading ends an analytics session; sessions may end before finalization, and multiple roles cannot be joined without the excluded identifiers. API/MCP operations and failed actions are not tracked. Begin analysis with separate event counts and same-page-session steps, and retain this sampling limitation in reports.
The privacy policy separately discloses the existing edge-injected Cloudflare Web Analytics beacon. That beacon is not governed by the optional PostHog choice; no Cloudflare setting is changed by this integration. Tests must intercept all ingestion and scheduling requests, use synthetic fixtures and never create real polls, responses, invitations or account/integration writes. Validate no ingest request before consent/after refusal, permitted payloads after opt-in, route redaction, withdrawal, cross-tab changes, blocked storage and network failures.
Client Node tooling (shadcn, the Tailwind PostCSS adapter, Vite and PostCSS)
belongs in the devDependencies section of client/package.json; it is not
imported by application JavaScript.
The existing React/Radix components remain the browser UI—updating the shadcn CLI
does not regenerate them. Tailwind's CSS entry and the pinned shared fonts/tokens
are still consumed when building the static client.
Install dev dependencies for a client build, even when preparing production:
cd client && npm ci --include=dev && npm test && npm run build. CI installs them
by default, and Railway explicitly uses --include=dev before building. The
running Express server serves client/dist, not the CLI or Vite dev server.
Evaluate the full client audit as well as dependency roles: moving a package
to dev-only does not repair it or eliminate developer/build/supply-chain risk.
cd server && npm test # Validation + route integration tests (Node built-in test runner)Copy server/.env.example and fill in:
PORT=3000
CLIENT_URL=http://localhost:5173
ATPROTO_CLIENT_ID=https://<your-domain>/api/auth/client-metadata-v4.json
ATPROTO_REDIRECT_URI=https://<your-domain>/api/auth/callback
SESSION_SECRET=<random string>
RESEND_API_KEY=<from resend.com>
ATPROTO_CLIENT_ID is the public URL where your server serves its OAuth client metadata — bsky fetches it during sign-in, so it must be reachable over HTTPS from the public internet. The path is versioned (v4) so you can rotate the client_id when you change the requested scopes — bsky caches grants per client_id URL and won't re-prompt users on scope changes within the same client_id. Bump the version to force fresh consent.
Participants can connect their Google Calendar to see busy times on the grid. To enable:
- Go to console.cloud.google.com
- Create a project (or use an existing one)
- Enable the Google Calendar API (APIs & Services > Library > search "Google Calendar API" > Enable)
- Go to APIs & Services > Credentials > Create Credentials > OAuth Client ID
- Application type: Web application
- Authorized JavaScript origins:
https://<your-domain>(andhttp://localhost:5173for local dev) - No redirect URI needed (uses Google Identity Services client-side flow)
- Copy the Client ID (looks like
123456789-abc.apps.googleusercontent.com)
Set it as an environment variable:
VITE_GOOGLE_CLIENT_ID=123456789-abc.apps.googleusercontent.com
Note: VITE_ prefix means it's baked into the client build — needs a redeploy after changing.
Google OAuth consent screen requirements:
- Set to "External" for public access
- Privacy policy URL:
https://<your-domain>/privacy - Terms URL:
https://<your-domain>/terms - Verify domain ownership via Google Search Console
- To go to production: the
calendar.readonlyscope requires a demo video showing how the app uses calendar data. Until approved, only manually added test users can connect their calendar. Record a screen capture of: open poll → click Connect Google Calendar → grant access → busy times appear on grid.
Two custom record types in lexicons/:
chat.avails.scheduling.poll — the scheduling poll (dates, time range, slot duration, community tag, notification preferences). Stored in the creator's PDS.
chat.avails.scheduling.response — a participant's availability (name, selected time slots, optional email). Stored in the creator's PDS on their behalf.
TypeScript types are generated with npx @atproto/lex build.
| Method | Route | Auth | Description |
|---|---|---|---|
| GET | /api/auth/login?handle= |
— | ATProto OAuth flow |
| GET | /api/auth/callback |
— | OAuth callback |
| GET | /api/auth/session |
cookie | Current user |
| POST | /api/polls |
ATProto | Create poll |
| GET | /api/polls/:did/:rkey |
— | Get poll + responses |
| GET | /api/polls?community=X |
— for public; member for private | List community polls (private community membership verified via Community Admin JWT or Avails sign-in) |
| PUT | /api/polls/:did/:rkey |
ATProto | Update poll (creator only) |
| DELETE | /api/polls/:did/:rkey |
ATProto | Delete poll (creator only) |
| PUT | /api/polls/:did/:rkey/finalize |
ATProto | Pick a time, send invites |
| POST | /api/polls/:did/:rkey/responses |
— | Submit availability |
| PUT | /api/polls/:did/:rkey/responses/:rkey |
— | Update response |
| DELETE | /api/polls/:did/:rkey/responses/:rkey |
— | Delete response |
| GET | /api/communities |
— | List communities |
| POST | /api/availability |
ATProto | Publish standing availability; ca-event writes require an active Community Admin event grant |
| GET | /api/availability/mine |
ATProto | List your standing-availability records |
| DELETE | /api/availability/:rkey |
ATProto | Delete your standing-availability record |
Private-community gating protects poll discovery through Avails' REST and MCP lists, not a poll's direct link or its world-readable ATProto PDS record. The My Community dashboard supplies its Community Admin member token when requesting a private feed; Avails verifies it against Community Admin's public signing keys.
POST /mcp — see Connect your AI assistant above for setup and usage. Full tool reference:
| Tool | Auth | Description |
|---|---|---|
get_poll |
— | Get poll details + responses + best available time slots |
list_polls |
— for public; member for private | List polls by community and/or status |
list_communities |
— | All communities with named topics |
create_poll |
ATProto | Create a new scheduling poll |
list_my_polls |
ATProto | List your polls |
update_poll |
ATProto | Update one of your open polls |
delete_poll |
ATProto | Delete one of your polls |
schedule |
ATProto | Pick the best time, close poll, send calendar invites |
share_poll |
ATProto | Post poll link to a community's Telegram group or channel |
publish_to_community_feed |
ATProto | Publish or unpublish a poll on its community dashboard |
evaluate_availability_overlap |
service | Read-only event-scoped overlap evaluation; no booking or invitation side effects |
schedule_call |
ATProto or service | Book directly from standing availability when coverage is sufficient |
Authentication: Standard OAuth 2.0 with ATProto — Claude Code handles the flow automatically via /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server discovery. Granular scopes (only poll and response record access, not full account).
MCP access tokens last 24 hours. After sign-in, the client receives a rotating refresh token (90-day inactivity window); Avails stores only its hash on the server volume. Clients that signed in before refresh tokens were added have no refresh token and need one new OAuth sign-in after deployment. Merely reconnecting the MCP transport does not renew identity.
Avails is part of the Citizen Infrastructure ecosystem — community tools built on open protocols.
- my-community — online community dashboard as a new tab
- dear-neighbors — neighborhood dashboard
- community-admin — admin platform for community organizers to manage participation opportunities
- navidrome-jam — synchronized music listening
Next — calendar integrations:
- Server-side calendar OAuth
- Create calendar events directly via API on finalize (not just .ics email)
Next — polish and ecosystem:
- Human-readable poll URLs via slug —
/p/cibc-season-2instead of DID paths - Open Graph metadata — rich previews in Telegram, Slack, social media
- Persistent availability — fill once, apply to all overlapping polls, auto-update on scheduling
- Decouple response storage from creator session — partially addressed: resilient session restore with lazy on-demand reconnection (#42 comment). Full decoupling (server-managed identity) is future work.
- Self-service community connection — users connect their own Telegram groups (blocked by community-admin)
- CLI tool —
avails create,avails sharefrom the terminal
Later — ecosystem:
- Telegram bot for reminders and invites (Railway service #2)
- Video conferencing links — Zoom, Google Meet, Jitsi
- Dear Neighbors integration — poll banners in DN participation feed
Endgame — instant scheduling from chat:
Team members connect their calendars once. Then anyone can run a command in Telegram or Discord:
@avails schedule @alice @bob @carol next week 1 hour
The bot queries everyone's calendars, finds the first open slot, and books it. No poll, no grid, no back-and-forth. This turns avails from an async scheduling tool into a real-time scheduling engine for teams that already share calendar access.
Requires: #6 (persistent calendar tokens) + #9 (Telegram bot) + identity linking.
Vision — shared ATProto identity across the ecosystem:
Today, My Community uses Bluesky app passwords for the Bluesky feed, and avails has its own ATProto OAuth login. These are separate auth sessions. When the ecosystem adopts ATProto OAuth as the shared identity layer, several things unlock:
- Zero-friction scheduling from MC/DN. Click an avails poll banner and go straight to painting availability — your identity is already known.
- Create polls without leaving the dashboard. Embed the poll creator directly in MC. Your OAuth session writes to your PDS.
- Smarter banners. MC shows "You haven't responded yet" vs "You responded (3 slots)" by checking responses against your DID.
- One login for everything. Sign into MC once, authenticated everywhere. ATProto DID as universal identity.
Identity and data as protocol primitives, not per-app silos.
AGPL-3.0