Skip to content
Citizen-InfraPublic

About

ATProto-powered group scheduling — open-source LettuceMeet alternative

Resources

Stars

7 stars

Watchers

0 watching

Forks

Repository files navigation

avails

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.

How it works

  1. Sign in with Bluesky — your poll is stored as an ATProto record in your PDS
  2. Pick dates and times — single-page creation, no multi-step wizard
  3. Share the link — participants paint their availability on a drag-to-select grid. No account needed.
  4. Pick a time — the heatmap shows where everyone overlaps. Finalize and calendar invites (.ics) are sent automatically.

Why ATProto?

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.

Features

  • 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 .ics invite 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.

Connect your AI assistant

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/mcp

Then 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

Stack

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
Email Resend + ical-generator
Timezone Luxon (DST-safe conversion between creator and viewer timezones)
Deploy Railway

Development

# Server (API + ATProto OAuth)
cd server && npm install && npm run dev

# Client (React SPA)
cd client && npm install && npm run dev

The client dev server proxies /api requests to the Express server on port 3000.

Shared frontend foundation and gates

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.

Social previews

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:

  1. Start the client with npm run dev in client/ and open /scripts/social-preview.html in Chromium. Use a 1200×630 viewport, device scale factor 1 and 100% zoom; wait for document.fonts.ready and all 56 .slot elements.
  2. Capture the .social-preview element as PNG to client/public/og-image-open-index.png (Chromium DevTools' Capture node screenshot, or Playwright's locator('.social-preview').screenshot()). Check the actual output is 1200×630. Preserve an original capture while adding metadata.
  3. 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.
  4. 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.
  5. 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.

Optional product analytics

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.

Tests

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)

Environment variables

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.

Google Calendar integration (optional)

Participants can connect their Google Calendar to see busy times on the grid. To enable:

  1. Go to console.cloud.google.com
  2. Create a project (or use an existing one)
  3. Enable the Google Calendar API (APIs & Services > Library > search "Google Calendar API" > Enable)
  4. Go to APIs & Services > Credentials > Create Credentials > OAuth Client ID
  5. Application type: Web application
  6. Authorized JavaScript origins: https://<your-domain> (and http://localhost:5173 for local dev)
  7. No redirect URI needed (uses Google Identity Services client-side flow)
  8. 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.readonly scope 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.

ATProto Lexicons

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.

API

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.

MCP endpoint

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.

Part of Citizen Infrastructure

Avails is part of the Citizen Infrastructure ecosystem — community tools built on open protocols.

Roadmap

Next — calendar integrations:

Next — polish and ecosystem:

Later — ecosystem:

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.

License

AGPL-3.0

About

ATProto-powered group scheduling — open-source LettuceMeet alternative

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages