---
name: scheduling
description: "Calendar and scheduling — create events, manage appointments, set up recurring triggers."
account-owned-files: [{"file": "calendar-availability.json", "description": "The booking page's availability config, written by the publish-availability script and read by the live booking page."}]
tools:
  - name: schedule-event
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: schedule-list
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: schedule-get
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: schedule-update
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: schedule-cancel
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: schedule-export-ics
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: schedule-import-ics
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: schedule-archive-ics
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: schedule-connector-busy-sync
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: time-resolve
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
skills:
  - skills/daily-prep/SKILL.md
  - skills/briefing/SKILL.md
always: false
embed: false
metadata: {"platform":{}}
mcp:
  command: node
  args:
    - ${PLATFORM_ROOT}/lib/mcp-spawn-tee/dist/index.js
    - ${PLATFORM_ROOT}/plugins/scheduling/mcp/dist/index.js
  env:
    MCP_SPAWN_TEE_NAME: scheduling
    LOG_DIR: ${LOG_DIR}
    PLATFORM_ROOT: ${PLATFORM_ROOT}
    ACCOUNT_ID: ${ACCOUNT_ID}
    SESSION_ID: ${SESSION_ID}
    AGENT_SLUG: ${AGENT_SLUG}
mcp-manifest: auto
---

# Scheduling

Manages events, appointments, and recurring triggers in the graph. Events are discoverable via memory-search.

## When to create events

Create events immediately when the user identifies something time-bound — an appointment, a reminder, a recurring briefing. Confirm after creation; don't ask permission first.

## Who owns calendar truth

This store is one input to the operator's availability, not the whole of it. Whether a connector
calendar even applies depends on the seat you are in, which you read from the tools loaded in this
session: the admin/claude.ai seat has claude.ai connector tools; a client sub-account session, or any
other non-connector seat, has none.

When this session has connector tools loaded, the operator's real calendar can live on a claude.ai
connector (Google Calendar, Microsoft 365) that only that seat can see; these scheduling tools cannot
read it. So the internal store is authoritative for what the booking site publishes, but it is not, by
itself, the answer to "what is on the calendar" or "am I free" when a connector calendar exists. The
admin session is the seat that reconciles the two, because it is the only one that sees both. A
specialist holds these scheduling tools for mechanical writes the admin delegates with the
connector-derived facts already resolved, not for deciding calendar truth from this store alone.

When this session has no connector tools, do not suggest connecting a claude.ai connector; that seat
structurally cannot hold one, and a client sub-account never can. The calendar answer this seat owns is
these internal scheduling tools: `schedule-event`, `schedule-list`, and the rest for live create and
manage, plus a one-time `schedule-archive-ics` import that pulls an existing Google, Outlook, or Apple
calendar into the graph from an `.ics` export. Full live per-account Google Calendar integration is
is planned; until it ships, the internal calendar plus `.ics` import is the client path.

Automatic connector-to-internal availability sync is separate future work.

## Meetings vs events — what `schedule-event` writes

`schedule-event` writes one of two graph nodes, decided from the parameters you pass — never from keywords in the title:

- A **`:Meeting`** when the thing is a real calendar appointment: you pass `attendees` with a counterparty (an `email` or a resolved `nodeId`), **or** you pass a bounded slot (both `startDate` and `endDate`) with no `recurrence` and no `action`. A meeting is `(accountId, uid)`-idempotent (the uid is a content hash of account + title + start, so re-booking the same appointment writes no duplicate) and carries `(:Person)-[:ATTENDED]->(:Meeting)` edges for each resolvable counterparty. This is the same node shape that `.ics` import and public-booking reconcile write, so one real meeting never ends up as both a meeting and an event.
- An **`:Event`** when the thing is a scheduler primitive: a `recurrence` (recurring trigger), an `action` (tool auto-dispatch), or an `agentDispatch` (agent-turn dispatch) is present, **or** it is a bare one-time reminder (a `startDate` with no end and no counterparty).

To book a meeting with someone, resolve the counterparty to a `:Person` first (via `memory-search`) and pass their `nodeId` in `attendees`; pass `email` (with optional `name`) when you only have an address and want a stub created. A name-only attendee is informational and links no person — it does not, by itself, make the write a meeting.

The chosen bucket is logged on every write: `[schedule-event] op=write label=Meeting|Event reason=<discriminator>`.

Backfilling already-written `:Event` appointments into `:Meeting` is a separate divergence-driven migration, not part of this write path.

## Where schedule-event can be called from

Anywhere a session runs, WhatsApp included. Every write anchors to the calling
session's `:Conversation` (`PART_OF`), so the session must have one.

If that anchor is missing the tool refuses, and the refusal names a platform
fault rather than anything the caller did:

```
Write doctrine violated: no :Conversation is anchored on this session
(sessionId=…), so the Event has nothing to link (PART_OF).
```

Retrying or rewording will not clear it — the anchor is written at spawn, not
by the tool. Report it. Until the fix this affected every WhatsApp seat and
was misread as a scheduling outage: the agent told the operator it "can't create
new automated triggers right now" and offered a manual workaround, when in fact
no WhatsApp session had ever been able to create one. See
[`.docs/session-conversation-identity.md`](../../../.docs/session-conversation-identity.md).

## One-time vs recurring

- **One-time events** have a `startDate` and optionally an `endDate`. They transition to `due` when their time arrives (via the platform cron) and to `completed` when the agent or user marks them done.
- **Recurring events** have a `recurrence` (cron expression) and a computed `nextRun`. They stay in `scheduled` status permanently. The cron advances `nextRun` and sets `lastTriggered` each cycle.

## Cron expressions

Use standard 5-field cron syntax: `minute hour day-of-month month day-of-week`.

| Pattern | Meaning |
|---------|---------|
| `0 8 * * 1-5` | Weekdays at 8am |
| `0 9 * * 1` | Every Monday at 9am |
| `0 0 1 * *` | First of each month at midnight |
| `*/30 * * * *` | Every 30 minutes |

## Skip next

For recurring events, `schedule-update` with `skipNext: true` advances `nextRun` by one cycle without triggering. Use when the user says "skip tomorrow's briefing" or similar.

## Cancellation

`schedule-cancel` sets status to `cancelled`. For recurring events this kills the entire series. There is no per-occurrence cancellation — use skip-next for that.

## Event actions

Events can carry an automated action that fires when the event's time arrives. The dispatcher binary that reads due events and spawns the target plugin's MCP server lives at `scheduling/mcp/dist/scripts/check-due-events.js`. It is **armed by the installer**, which registers one install-level cron line (`# BEGIN <BRAND> CRONS` block) that ticks the dispatcher once a minute. The watcher scans every account itself and only acts on operator-created bookings whose time has come, so a stored `action:` payload executes within about a minute of its `startDate` with no operator present. (The email dispatchers, `email-fetch` and `email-auto-respond`, act without an operator booking and stay unscheduled by the installer; see `maxy-code-prd.md` §Scheduled tasks.)

To create an event with an action, pass the `action` parameter to `schedule-event`:

```json
{ "plugin": "memory", "tool": "memory-search", "args": { "query": "open commitments" } }
```

- `plugin` — the plugin directory name under `$PLATFORM_ROOT/plugins/` (validated at creation — must exist)
- `tool` — the MCP tool name to call (validated at dispatch — if it doesn't exist, the error is logged)
- `args` — optional arguments passed to the tool call

Actions are stored as flat properties on the Event node: `actionPlugin`, `actionTool`, `actionArgs` (JSON string). Use `schedule-update` with `action` to modify or `action: null` to clear.

Dispatch results are recorded on the Event node (`lastDispatchResult`, `lastDispatchAt`) and appended to the notes history. The admin can also read dispatch logs via `logs-read` with `type: "heartbeat"`.

Users and plugins both create events with actions via the same `schedule-event` interface — the mechanism is symmetric.

## Agent-bearing dispatch

An `action` invokes one pre-encoded MCP tool with no reasoning. When a due event needs an agent to decide what to do and say — "when this document is signed, work out what to tell me and message me on WhatsApp" — pass `agentDispatch` instead. When the event fires, the heartbeat hands the prompt to the destination's native channel: the channel spawns or resumes the deterministic admin session for that phone or chat, the agent runs the prompt with full admin tools, and its answer is delivered back over the account's paired socket. No operator session need be open.

```json
{ "channel": "whatsapp", "destination": "+447700900123", "prompt": "Check whether the tenancy agreement is signed yet and, if it is, message me a one-line confirmation." }
```

- `channel` — `whatsapp` or `telegram`.
- `destination` — the E.164 phone (WhatsApp) or chat id (Telegram) to run the turn for and reply to. It **must already be a registered admin or account-manager** on that channel; a destination that is not is rejected when the event is created. For WhatsApp the authoritative admin/manager lists are read from the socket-owning **house** account (never the event's own account), and the destination's registered account must match the account that created the schedule: an admin belongs to the house, a manager to its bound sub-account. The house/owner account may target any registered admin or manager; a sub-account is locked to its own manager(s). A refusal names the true reason — `not-registered` (in no house list) or `cross-account` (registered to a different account, naming both accounts).
- `prompt` — the instruction the agent runs.

An event carries **either** an `action` **or** an `agentDispatch`, never both — setting one clears the other. Agent dispatch is stored as `agentChannel`, `agentDestination`, `agentPrompt`. Use `schedule-update` with `agentDispatch` to modify or `agentDispatch: null` to clear. On WhatsApp, a destination bound as an account-manager scopes the spawned session to its sub-account; on Telegram the session runs on the house account.

A WhatsApp `agentDispatch` destination may therefore be an account manager, not only an admin phone — this is the supported way to send a manager a proactive or scheduled WhatsApp message. The reply is delivered over the house's paired line regardless of which sub-account the session is scoped to; socket ownership never restricts which number the dispatch can reach.

Dispatch results are recorded on the Event node (`lastDispatchResult`, `lastDispatchAt`) exactly as for tool actions. Every heartbeat tick also audits stored agent dispatches against the same house-authority rule used at creation, and logs `[schedule-audit] op=stale-dispatch` with the identical reason codes — `not-registered` (destination left the house lists), `cross-account` (rebound to a different account than the one that created the schedule), or `sub-account-missing` (bound sub-account deleted) — so a binding that rots between scheduling and firing is surfaced with its true cause before it mis-dispatches.

## Reconciling public bookings (D1 → graph)

`scheduling/mcp/dist/scripts/reconcile-bookings.js` turns public bookings into calendar meetings. The booking site (deployed by the cloudflare `calendar-site` skill) writes each submission to a Cloudflare D1 `bookings` table at the edge, so capture survives a brief Pi outage. This binary pulls eligible rows (`status='accepted' AND kind='booking'`) from each account's D1, claims the ones with no matching `:Meeting`, writes one `:Meeting {source:'booking', uid:'booking-<id>'}` per row (idempotent — a re-run writes no duplicate), links the booker as a `:Person` via `[:ATTENDED]` (respecting the global email-uniqueness skip), notifies the admin (email; WhatsApp only when a thread already exists, never cold), then marks the row swept. A row is claimed on the absence of its meeting rather than on `swept`, so a meeting that is later deleted or lost is rewritten on the next run; `swept` records only that a write was attempted. `kind='block'` rows reserve a slot and are never reconciled.

It is **armed by the always-running `platform/ui` server on an interval**, because the Pi has no other periodic surface (the same gap `check-due-events` has). A PID lock at `<configDir>/reconcile-bookings.lock` prevents overlapping runs. It registers **no agent tool** — there is nothing for the agent to call — so the tool-surface registries are untouched.

Env: `PLATFORM_ROOT`, `NEO4J_URI` (and `NEO4J_PASSWORD` or `config/.neo4j-password`). Per account it reads `data/accounts/<id>/calendar-availability.json` (for `bookingDbName`). The Cloudflare credential is read house-only from `config/cloudflare-house.env`; that credential is a minter, so the calendar mints a `calendar-d1` (D1-only) token from it via `cf-token.sh` and shells `wrangler` for the D1 reads/writes on that scoped token, never the minter directly.

Observability: one `[calendar-booking]` line per step keyed by `bookingId` (`reconcile-claim`, `meeting-write`, `notify`, `booked`), and one `[calendar-reconcile] account=… eligibleExamined=… pendingUnreconciled=… pendingOldestAgeMin=… orphanMeetings=…` audit line per account per run. `eligibleExamined` separates "nothing to do" from "verified clean". A `pendingUnreconciled` that persists across runs means those rows are failing to become meetings rather than merely lagging — the `attempt=` count on the claim and failure lines says how many times each has been tried. `orphanMeetings` means a booking meeting has no backing eligible D1 row. `pendingOldestAgeMin` ages that backlog: a count cannot separate a short lag from a nine-hour one. The companion `[calendar-reconcile] op=liveness lastRunAgeMin=… lastAuditAgeMin=… lastSkipReason=… stale=<bool>` line is emitted every ten minutes by the platform/ui server rather than by this script, because the failure it detects is this script not reporting at all. `op=liveness-write-failed` is the one case where that record could not be written; it carries the error body and never stops the run, because a run that worked must not be reported as failed for losing its own bookkeeping.

## Ingesting calendar archives (.ics → graph)

`schedule-archive-ics` is the bulk-ingest path: it reads an `.ics` export from any calendar app (Google, Apple, Outlook, Fastmail, etc.) and writes one `:Meeting` per VEVENT into the graph, with attendees resolved to `:Person` by lowercased email. Distinct from `schedule-import-ics`, which only parses + returns events for the operator to recreate via `schedule-event` one at a time.

Two-mode flow:

- `mode: 'dry-run'` — parses the file, classifies every attendee against the existing graph, returns counts plus up to 20 sample resolutions (already-existing under this account, existing under a foreign account that will be skipped, new stub to be created, or non-mailto resource). No writes.
- `mode: 'commit'` — applies the import. Each meeting MERGEs on `(accountId, uid)` (idempotent re-ingest), anchors to the calling `:AdminUser` via `[:IMPORTED]`, and writes `(:Person)-[:ATTENDED]->(:Meeting)` edges with role + partstat.

Cross-account collision rule: when an attendee email already exists as a `:Person` under a different `accountId`, the import skips the attendance edge (no edge written, the foreign Person is untouched) and counts it as `skippedCrossAccountPerson`. Absolute brand isolation is preserved — one operator's import cannot graft attendance onto another operator's people.

Description bodies pass through unchanged on `:Meeting.description`. The typed-edge auto-extraction pass already iterates `:Meeting` and the `Meeting -[:MENTIONS]-> Person/Organization` plus `Person -[:ATTENDED]-> Meeting` schema, so commitments + mentions surface without extra extraction code in this plugin.

Out of scope on this surface: live Google/Outlook/Apple API sync, OAuth, incremental cursor, `:Day` aggregator, `:RecurringMeeting` parent, attendee auto-enrichment, write-back to the source calendar.

## Locale time formatting

All timestamps in tool responses (schedule-list, schedule-get, schedule-event) are formatted in the user's locale timezone with a relative delta. The user's timezone is read from `UserProfile.timezone` (IANA format, e.g. `Europe/London`).

Example output: `Thursday 3 April 2026, 12:00 BST (in 51 minutes)` instead of raw `2026-04-03T11:00:00.000Z`.

Relative deltas follow standard cutoffs: < 1 min ("just now"), minutes, hours, days (1–6), and no relative for 7+ days. Storage remains UTC — formatting is applied at output time only.

The platform auto-detects the server's IANA timezone and sets it on `UserProfile.timezone` at first login (and backfills existing profiles that lack one). Onboarding step 7 confirms the auto-detected timezone with the user. If `UserProfile.timezone` is still not set (e.g. Neo4j was unreachable during the backfill), the tool returns an error instructing the agent to set the timezone — there is no fallback to UTC. To set the user's timezone manually, use `profile-update` (memory plugin) with `profileFields: { timezone: 'Europe/London' }`. The timezone must be a valid IANA identifier (e.g. `Europe/London`, `America/New_York`, `Asia/Tokyo`).

## Time resolution

Any activity involving time (date, timestamp, day of week, month, duration) routes through `time-resolve` before any other reasoning: supply the ISO 8601 datetime to interpret and it returns the user's locale timezone view with a human-readable relative delta. Input format: `expression` is an ISO 8601 datetime string. `time-resolve` requires that input and cannot read the clock — the current instant itself (now, today, today's weekday) is not sourced here; it is in the injected `<datetime>` block, present on every turn. So: read "now" from the `<datetime>` block; to get the weekday or locale view of a *specific* datetime you already know, pass its ISO to `time-resolve`.

## Relationships

Link events to other graph entities using relationship types:

- `ABOUT` — what the event concerns (a Service, Task, etc.)
- `SCHEDULED_FOR` — who the event is for (a Person)
- `PART_OF` — which conversation created the event

The `sessionKey` parameter on `schedule-event` automatically creates the `PART_OF` relationship.

## ICS calendar files

Export events as `.ics` files so users can add them to Apple Calendar, Google Calendar, or Outlook. Use `schedule-export-ics` with the event IDs; it writes the `.ics` under the account's `output/ics/` directory and returns the path. Deliver that file to the operator (see the `file-presentation` skill for how delivery works on this surface).

Import `.ics` files uploaded by the user. Use `schedule-import-ics` with the attachment path. Present the extracted events for confirmation before creating them via `schedule-event`.

Cron→RRULE mapping for recurring events: daily, weekly-with-days, and monthly-by-date patterns produce standard RRULE properties. Cron patterns that cannot map to RRULE (e.g. `0 8 1,15 * *`) produce individual VEVENTs per occurrence instead.

## References
- plugins/business-assistant/references/scheduling.md (booking protocol, geographic clustering, briefings)
