@hasna/calendar
v0.3.9
Published
Universal calendar management for AI coding agents - CLI + MCP server + interactive TUI
Maintainers
Readme
@hasna/calendar
Universal calendar management for AI coding agents. The package ships a typed SDK,
a calendar CLI, a Model Context Protocol server, and a local HTTP API server
backed by SQLite.
Install
bun add @hasna/calendarThe package requires Bun. Installed binaries:
calendar- CLI for orgs, agents, calendars, events, attendees, availability, memberships, and event-log commands from@hasna/events.calendar-mcp- MCP server over stdio, or Streamable HTTP with--http.calendar-serve- local HTTP API server.
Storage And Configuration
By default, calendar data is stored in SQLite at:
~/.hasna/calendar/calendar.dbThe database location can be controlled with environment variables:
CALENDAR_DB_PATH=/absolute/path/calendar.dbuses an explicit database file.CALENDAR_DB_SCOPE=projectstores data under the nearest git root at.calendar/calendar.db.- If a
.calendar/calendar.dbexists in the current directory or a parent directory, that database is reused. BUN_TEST=1makes the SDK default to an in-memory database for tests.
The HTTP server uses CALENDAR_PORT and defaults to 19428. The MCP HTTP mode
uses MCP_HTTP_PORT and defaults to 8803.
Storage
There are no deployment modes. The data backend is selected by configuration on each side of the wire:
Server (calendar-serve) — HASNA_CALENDAR_DATABASE_URL (or
CALENDAR_DATABASE_URL) present selects the PostgreSQL backend; absent means
SQLite. The serve process talks to Postgres directly via that URL plus
HASNA_CALENDAR_API_SIGNING_KEY, and needs neither HASNA_CALENDAR_API_URL nor
HASNA_CALENDAR_API_KEY. A generic DATABASE_URL from another project is not
consulted at runtime; calendar-serve migrate accepts it because migration is
already an explicit database operation.
Client (calendar CLI, calendar-mcp, the SDK) — exactly two connections:
| Configuration | Connection |
| --- | --- |
| HASNA_CALENDAR_API_URL and HASNA_CALENDAR_API_KEY (or the CALENDAR_* aliases) both set | hosted API — every read/write goes to <API_URL>/v1 |
| neither set | local — on-box SQLite |
Setting only one of the pair is a hard failure (resolveStorageClient
throws): the client refuses to pick a data store the configuration does not
fully name. There is no default API host and no degraded path — silently
falling back to a different store is how a single process used to end up
reading two different datasets.
The client never opens Postgres directly.
SDK
The root package export is side-effect free and exposes types, database helpers, and CRUD helpers for orgs, agents, calendars, events, attendees, availability, and memberships.
import {
createOrg,
registerAgent,
createCalendar,
createEvent,
listEvents,
findConflicts,
searchEvents,
closeDatabase,
type Event,
} from "@hasna/calendar";
const org = createOrg({ name: "Platform" });
const agent = registerAgent({ name: "spark01", org_id: org.id });
const calendar = createCalendar({
org_id: org.id,
name: "Launch",
timezone: "Europe/Bucharest",
});
const event: Event = createEvent({
calendar_id: calendar.id,
org_id: org.id,
title: "Release review",
start_at: "2026-06-24T14:00:00+03:00",
end_at: "2026-06-24T14:30:00+03:00",
created_by: agent.id,
});
console.log(listEvents({ org_id: org.id, limit: 10 }));
console.log(findConflicts(calendar.id, { start: event.start_at, end: event.end_at }));
console.log(searchEvents("release", org.id));
closeDatabase();Useful exported helpers include:
- Database:
getDatabase,closeDatabase,resetDatabase - Orgs:
createOrg,getOrg,getOrgBySlug,listOrgs,updateOrg,deleteOrg - Agents:
registerAgent,getAgent,getAgentByName,listAgents,heartbeat,updateAgent,deleteAgent - Calendars:
createCalendar,getCalendar,listCalendars,updateCalendar,deleteCalendar - Events:
createEvent,getEvent,listEvents,updateEvent,deleteEvent,searchEvents,findConflicts,findAgentConflicts - Attendees:
createAttendee,getAttendeesForEvent,updateAttendee,deleteAttendee - Availability:
getAvailabilityForAgent,upsertAgentAvailability,deleteAvailability - Memberships:
createMembership,getMembershipsForOrg,getOrgsForAgent,deleteMembershipByAgentAndOrg
CLI
Calendar CRUD commands accept --json either globally or on the subcommand:
calendar --json org-add "Platform"
calendar org-list --jsonGlobal options:
--jsonoutputs JSON and serializes command errors as JSON.--agent <name>provides an agent name for commands that use agent context.--org <slug>is accepted as global org context for integrations; commands that need an org usually require an explicit--org <org-id>option.
Command groups:
calendar org-add <name> [--slug <slug>] [--description <desc>]
calendar org-list
calendar org-show <id-or-slug>
calendar org-update <id> [--name <name>] [--description <desc>]
calendar org-delete <id>
calendar init <name> [--description <desc>] [--role <role>] [--org <org-id>]
calendar agents
calendar heartbeat [agent]
calendar agent-update <id> [--description <desc>] [--role <role>]
calendar agent-delete <id>
calendar cal-add <name> --org <org-id> [--slug <slug>] [--description <desc>]
[--color <hex>] [--timezone <tz>] [--visibility public|org|private]
calendar cal-list [--org <org-id>]
calendar cal-update <id> [--name <name>] [--description <desc>]
[--color <hex>] [--timezone <tz>] [--visibility <visibility>]
calendar cal-delete <id>
calendar add <title> --calendar <calendar-id> --start <iso> --end <iso>
[--org <org-id>] [--description <desc>] [--location <loc>] [--all-day]
[--status tentative|confirmed|cancelled] [--busy busy|free|out_of_office]
[--timezone <tz>] [--rrule <rule>] [--source-task <id>] [--agent <agent-id>]
calendar list [--calendar <calendar-id>] [--org <org-id>]
[--after <iso>] [--before <iso>] [--limit <n>]
calendar show <id>
calendar update <id> [--title <title>] [--start <iso>] [--end <iso>]
[--description <desc>] [--location <loc>] [--status <status>]
calendar delete <id>
calendar search <query> [--org <org-id>]
calendar conflicts <calendar-id> --start <iso> --end <iso>
calendar attendee-add --event <event-id>
[--agent <agent-id>] [--name <name>] [--email <email>] [--required|--optional]
calendar attendee-respond <attendee-id> --status accepted|declined|tentative
[--comment <comment>]
calendar attendee-delete <id>
calendar availability-set --agent <agent-id> --org <org-id>
--day <0-6> --start <HH:mm> --end <HH:mm>
calendar availability-show <agent-id> [--org <org-id>]
calendar availability-delete <id>
calendar member-add --org <org-id> --agent <agent-id>
[--role admin|member|service]
calendar members <org-id>
calendar member-remove <agent-id> <org-id>
calendar agent-orgs <agent-id>The CLI also registers events and webhooks command groups from
@hasna/events for local event-log and webhook operations:
calendar events --help
calendar webhooks --helpCompact Output And Gradual Disclosure
Human-readable list and search commands are compact by default so agent terminals do not fill with full records. Default output shows essential fields, caps the first page at 20 rows, and prints a hint for the next step.
Use these flags to disclose more detail:
--limit <n>changes the number of rows in the current page (max 100).--cursor <n>starts from a later zero-based row offset.--verboseadds secondary fields such as descriptions, locations, IDs, and timestamps without switching to JSON.--jsonkeeps machine-readable output as the existing full JSON record array unless paging is explicitly requested with--limitor--cursor.--json --limitor--json --cursorreturns a pagination envelope:{ "items": [...], "total": 42, "limit": 20, "cursor": 0, "next_cursor": 20 }.- Detail commands such as
calendar show <id>andcalendar org-show <id>return a focused record when you know the ID.
calendar list --calendar cal_123
calendar list --calendar cal_123 --cursor 20
calendar list --calendar cal_123 --limit 5 --verbose
calendar show evt_123
calendar list --calendar cal_123 --jsonMCP list/search tools use the same gradual disclosure model. They return compact
summary envelopes by default and accept limit, cursor, and verbose fields
where applicable.
Common CLI Workflow
ORG_JSON=$(calendar --json org-add "Platform")
ORG_ID=$(bun -e 'console.log(JSON.parse(process.argv[1]).id)' "$ORG_JSON")
AGENT_JSON=$(calendar --json init spark01 --org "$ORG_ID" --role dispatcher)
AGENT_ID=$(bun -e 'console.log(JSON.parse(process.argv[1]).id)' "$AGENT_JSON")
CAL_JSON=$(calendar --json cal-add "Engineering" --org "$ORG_ID" --timezone UTC)
CAL_ID=$(bun -e 'console.log(JSON.parse(process.argv[1]).id)' "$CAL_JSON")
calendar --json add "Release review" \
--calendar "$CAL_ID" \
--org "$ORG_ID" \
--start "2026-06-24T14:00:00Z" \
--end "2026-06-24T14:30:00Z" \
--agent "$AGENT_ID"
calendar --json list --calendar "$CAL_ID" --limit 5
calendar --json conflicts "$CAL_ID" \
--start "2026-06-24T14:10:00Z" \
--end "2026-06-24T14:20:00Z"MCP Server
Start the stdio MCP server:
calendar-mcpExample MCP client configuration:
{
"mcpServers": {
"calendar": {
"command": "calendar-mcp"
}
}
}The MCP server exposes tools for orgs, agents, calendars, events, attendees, availability, memberships, and bootstrap:
create_org, list_orgs, get_org
register_agent, list_agents, heartbeat
create_calendar, list_calendars
create_event, list_events, get_event, update_event, delete_event
search_events, find_conflicts
add_attendee, list_attendees, respond_to_event
set_availability, get_availability
add_member, list_members
bootstrapStart Streamable HTTP MCP mode:
calendar-mcp --http --port 8803
curl http://127.0.0.1:8803/healthEnvironment equivalent:
MCP_HTTP=1 MCP_HTTP_PORT=8803 calendar-mcpIn HTTP mode, MCP requests are served at /mcp.
HTTP API Server
calendar-serve exposes exactly three kinds of surface. Nothing else is mounted.
Route census
| # | Route | Methods | Auth | Store reached | Carries |
| --- | --- | --- | --- | --- | --- |
| 1 | /health | GET | public | none | metadata |
| 2 | /version | GET | public | none | metadata |
| 3 | /ready | GET | public | select 1 round-trip only | metadata |
| 4 | /openapi.json | GET | public | none | metadata |
| 5 | /v1 | any | API key | none (banner) | metadata |
| 6 | /v1/orgs[/:id] | GET POST PATCH PUT DELETE | API key | Postgres | data |
| 7 | /v1/calendars[/:id] | GET POST PATCH PUT DELETE | API key | Postgres | data |
| 8 | /v1/events[/:id], /v1/events/search, /v1/events/conflicts | GET POST PATCH PUT DELETE | API key | Postgres | data |
| 9 | /v1/attendees[/:id] | GET POST PATCH PUT DELETE | API key | Postgres | data |
| 10 | /v1/agents[/:id[/heartbeat]] | GET POST PATCH PUT DELETE | API key | Postgres | data |
| 11 | /v1/availability[/:id] | GET POST DELETE | API key | Postgres | data |
| 12 | /v1/members | GET POST DELETE | API key | Postgres | data |
| 13 | /v1/<unknown> | any | API key | none | metadata (404) |
| 14 | /mcp | POST GET DELETE (+ OPTIONS) | auth posture (below) | getStore(), 23 tools | data |
| 15 | OPTIONS (non-/v1, non-/mcp) | OPTIONS | public | none | metadata (CORS) |
| 16 | anything else | any | public | none | metadata (404) |
Routes 1-4 are metadata-only and stay public in every configuration: they are the
service-contract probes an ALB target group and a container healthcheck depend on.
/v1 authenticates itself with the @hasna/contracts API-key verifier (reads need
calendar:read, writes need calendar:write).
Known quirks — both pre-existing, both CORS-preflight-only, neither fixed here:
OPTIONS /v1/...is claimed by the/v1handler and treated as a write, so it answers 401 rather than returning CORS headers.OPTIONS /mcpis claimed by the/mcproute and goes through the auth posture, so it answers 401 inenforceand 404LOCAL_PLANE_DISABLEDwhen the local plane is disabled — in neither case does it return CORS headers. Only routes 15/16 (everything that is neither/v1*nor/mcp) get a real CORS preflight response. Consequence: a browser cannot call/v1or/mcpcross-origin. Both surfaces are server-to-server today, so this is documented rather than changed.
Auth posture for /mcp
/mcp is a full read/write data plane (create_org, register_agent,
create_event, update_event, delete_event, add_member, …). The posture is
resolved once at startup, before the socket is bound:
| Configuration | Posture | /mcp | /v1 | probes |
| --- | --- | --- | --- | --- |
| CALENDAR_SERVE_API_KEY (or --api-key) | enforce | credential required | authenticated | public |
| hosted (an app-scoped database URL, HASNA_CALENDAR_DATABASE_URL) with no serve key | local-plane-disabled | 404 LOCAL_PLANE_DISABLED — not mounted | authenticated | public |
| loopback bind and --allow-anonymous (or CALENDAR_ALLOW_ANONYMOUS=1) | anonymous-loopback | anonymous, loopback peers only | authenticated | public |
| anything else | — | the server refuses to start, exit 1 | — | — |
--allow-anonymous is refused outright for a non-loopback bind host, and even when
active a request is only served anonymously if its raw transport peer is loopback
(x-forwarded-for is deliberately ignored, so a proxy header cannot forge it).
On a hosted deployment, setting CALENDAR_SERVE_API_KEY without also setting
HASNA_CALENDAR_API_URL + HASNA_CALENDAR_API_KEY is refused at startup
(SPLIT_STORE_PLANE): /v1 would be on Postgres while /mcp was on on-box SQLite.
CALENDAR_SERVE_API_KEY is intentionally a different variable from the client-flip
CALENDAR_API_KEY / HASNA_CALENDAR_API_KEY: those point the CLI/MCP at a remote
/v1, and reusing them here would flip getStore() to the API store as a side effect
of configuring the server's own auth.
Running it
# local dev, loopback only
calendar-serve --allow-anonymous
# local with a shared credential
CALENDAR_SERVE_API_KEY=<key> calendar-serve
# hosted (ECS/RDS): /v1 only, /mcp not served.
# No HASNA_CALENDAR_API_URL / HASNA_CALENDAR_API_KEY here — those are client-side.
HASNA_CALENDAR_DATABASE_URL=<dsn> calendar-servecurl http://127.0.0.1:19428/health
curl http://127.0.0.1:19428/ready
curl -H "x-api-key: <key>" http://127.0.0.1:19428/v1/orgsDevelopment And Validation
bun install
bun run typecheck
bun test
bun run build
bun pm pack --dry-runFocused smoke checks:
bun run src/cli/index.tsx --version
bun run src/cli/index.tsx --json org-list
bun run src/mcp/index.ts --http --port 8803License
Apache-2.0. See LICENSE.
