Sands
Skill indigokarasu/sands
Calendar management. Use for viewing, querying, creating, modifying, deleting, or analyzing calendar events. Handles natural-language scheduling, conflict detection with flexibility classification, free slot finding, automatic travel time event insertion between consecutive appointments, recurring event management, and daily schedule briefings for Vesper. Do not use for reminders without calendar context, task management, or general time/timezone questions.From its SKILL.md
npx -y skills add indigokarasu/sandsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its file declares
Copied from the file, not written here
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
24.9 KB, ~5.5k tokens by cl100k_base, as published. Nobody here has run it
Interactive Menu
When invoked interactively (via / command), present a two-level menu. See references/interactive-menu.md for the menu structure and response parsing logic.
Sands
Sands manages calendar events through natural language — creating, querying, modifying, and deleting events across personal and work calendars. It detects scheduling conflicts with flexibility classification, finds free time slots, inserts travel time blocks via Google Places API, and emits structured schedule briefs to Vesper for morning and evening briefings.
When to Use
- Calendar event creation, modification, and deletion
- Multi-calendar coordination (Personal, Shannon, Family)
- Appointment scheduling with conflict detection
- Focus time and out-of-office management
- When any skill needs calendar operations
For example, when the user says "schedule a meeting with <operator> at 3pm tomorrow," Sands creates the event with conflict pre-check and smart duration defaults.
When NOT to Use
- Email or message sending (use Dispatch)
- Content generation or research
- Booking non-calendar appointments (use Spot)
- Travel planning (use Voyage)
Responsibility boundary
Sands owns calendar event management, conflict analysis, flexibility classification, travel time insertion via Google Places API, and emitting schedule signals to Vesper.
Sands does not own: communications (Dispatch), travel reservations (Voyage), general research (Sift), entity knowledge (Weave).
Ontology types
Sands works with these types from spec-ocas-ontology.md:
- Place — event locations resolved via Google Places API during
sands.logistics.travel. Location data retained indecisions.jsonlas decision context only. - Event (Concept subclass) — calendar events managed through Google Calendar, not Chronicle.
Sands queries entity context from:
- Weave (read-only) — attendee identity resolution during conflict classification
- Chronicle — current location context for travel departure resolution
Commands
sands.calendar.query— pull events for a time window; merged view with work busy overlaysands.event.create— create event from natural language with conflict pre-check and smart duration defaultssands.event.modify— update event with recurring scope control and post-modify conflict re-checksands.event.delete— cancel event with travel block cleanup and recurring scope controlsands.event.undo— revert most recent calendar action (within 24 hours)sands.schedule.free— find available time slots for a given duration with constraintssands.schedule.conflicts— analyze time window for conflicts with flexibility classification. Seereferences/conflict-report-format.mdfor output template.sands.logistics.travel— insert travel time block between events via Google Places APIsands.briefing.generate— generate structured schedule summary for Vesper emissionsands.status— skill health, configured calendars, API connectivity, current timezonesands.journal— write journal for the current run; called at end of every runsands.update— pull latest from GitHub source; preserves journals and datasands.chronicle.sync— push travel, medical, and personal calendar events into Chronicle as persistent facts
See references/briefing_windows.md for morning/evening briefing time window definitions.
See references/credential-files.md for Google Places API key and OAuth token details, including token staleness handling.
Run completion
After every Sands command:
- Persist event interactions to
events.jsonl(event_id, calendar_id, title, start, end, action, recurrence_scope, previous_values) - Log material decisions (conflict resolutions, travel insertions) to
decisions.jsonl - Write journal via
sands.journal— Observation Journal for query/free/conflicts/status, Action Journal for create/modify/delete/travel/brief/undo
Post-mutation verification: After any create/modify/delete command, re-query the calendar for the affected event ID and confirm the change is reflected (correct title, time, calendar placement, or removal). If the event state does not match what was requested, log a calendar_mismatch entry in evidence.jsonl and alert the user — never silently assume the write succeeded.
Hard boundaries
- Never write to
work_calendar_id— read/overlay as busy blocks only - All-day events do not trigger conflicts with timed events unless explicitly asked
- Never auto-resolve conflicts — present options, let the user choose
- Never use a hardcoded home address or assume a fixed city for travel departure
- Never silently fall back to distance heuristics if Google Places API is unavailable — surface warning and ask for manual estimate
- Undo window is 24 hours; recurring event scope changes cannot be undone
Recovery Behavior
This skill implements the recovery contract from spec-ocas-recovery.md.
- Evidence: Every scheduled run writes an evidence record to
{agent_root}/commons/data/ocas-sands/evidence.jsonl, including no-op runs. Thenot_activity_reasonfield is mandatory when no side effects occur. - Gap detection: On every wake, checks the evidence log. If gap exceeds cadence (24h for briefs, 24h for conflict-scan), logs
gap_detected. - Degraded mode: When Google Calendar API or Google Places API fail, logs
degraded: <api>and continues with available data. - Log compaction: Evidence and decision logs older than 30 days (no-op) or 90 days (error/gap) compacted. Last 7 days retained.
Storage layout
See references/schemas.md for the full storage layout and default config.json.
OKRs
Universal OKRs from spec-ocas-journal.md apply to all runs. See references/okrs.md for details.
Optional skill cooperation
- Weave — attendee identity resolution and current location context
- Chronicle — current location or travel context
- Voyage — travel reservations detected in calendar surfaced for Voyage to manage
- Vesper — Vesper reads Sands schedule briefs at journal payload fields (see interfaces specification) during briefing generation (cooperative write; Sands pushes to Vesper (via journal briefing payload))
Journal outputs
- Observation Journal — sands.calendar.query, sands.schedule.free, sands.schedule.conflicts, sands.status
- Action Journal — sands.event.create, sands.event.modify, sands.event.delete, sands.event.undo, sands.logistics.travel, sands.briefing.generate
Initialization
On first invocation of any Sands command, run sands.init:
- Create
{agent_root}/commons/data/ocas-sands/directory - Write default
config.jsonwith ConfigBase fields if absent - Create empty JSONL files:
decisions.jsonl,events.jsonl,evidence.jsonl,intents.jsonl - Create
{agent_root}/commons/journals/ocas-sands/and ensure both journal files exist (create empty if absent):action.jsonl,observation.jsonl - Register cron jobs listed below if not already present (check the platform scheduling registry first)
- Log initialization as a DecisionRecord in
decisions.jsonl
Background tasks
Registered during sands.init. Always check existing jobs before registering:
| Job name | Schedule | Command | Purpose |
|---|---|---|---|
sands:morning-brief | 0 6 * * * | sands.briefing.generate | Today's schedule brief for Vesper |
sands:evening-brief | 0 20 * * * | sands.briefing.generate | Tomorrow's schedule brief for Vesper |
sands:conflict-scan | 0 7 * * * | sands.schedule.conflicts | Daily conflict scan for upcoming 7 days |
sands:travel-check | 0 7 * * * | sands.logistics.travel | Check next day's events for missing travel blocks |
sands:update | 0 0 * * * | sands.update | Self-update from GitHub source |
sands:chronicle-sync | 0 8 * * 0 | sands.chronicle.sync | Weekly calendar → Chronicle fact sync (Sundays 8 AM) |
All cron jobs use: --session isolated --light-context --tz America/Los_Angeles.
Registration during sands.init:
Check the platform scheduling registry for existing tasks before registering each job. Tasks are declared in SKILL.md frontmatter metadata.{platform}.cron.
Self-Update
See references/self-update-sands.md.
Visibility
public
Gotchas
-
⚠️ write_file OVERWRITES — JSONL append requires read-then-rewrite or the helper script — The
write_filetool replaces the entire file. NEVER callwrite_fileonevidence.jsonl,decisions.jsonl, orevents.jsonlwith only the new record — you will destroy all prior history. Two safe approaches:- Preferred: Use the
scripts/append_jsonl.pyhelper:terminal("python3 <skill_dir>/scripts/append_jsonl.py <path> '<json_record>'"). It reads, appends, rewrites, and verifies line count atomically. - Manual: (1)
read_filethe existing JSONL, (2) construct the full content (all existing lines + new line), (3)write_filewith the complete content. Always verify line count increased by 1 after writing. If you accidentally overwrite, check session context for the original contents to restore from.
- ⚠️ Unicode-safe append (emoji in titles) — Event titles routinely contain emoji (e.g.
🏺 Intro to Handbuilding). The helper takes the record as a shell-quoted positional arg, so passing JSON with emoji/nested quotes through the shell mangles the data. For any record that may contain non-ASCII or nested quotes, DON'T shell-quote intoappend_jsonl.py— insteadwrite_filea small Python script that opens the JSONL, filters blanks, appendsjson.dumps(record)+'\n', rewrites, and asserts line count increased (thenterminal("python3 <that_script>")). Building records as real Python dicts writes Unicode correctly viajson.dump. Seereferences/cron_persistence.mdfor the verified cron pattern and the full why.
- Preferred: Use the
-
Work calendar is read-only — Sands can overlay work calendar busy blocks but must never write to
work_calendar_id. Writing to a read-only calendar will fail silently or produce API errors. -
All-day events don't conflict with timed events — Per the hard boundary, all-day events are excluded from conflict detection with timed events unless the user explicitly asks. This can hide real scheduling issues if the user expects otherwise.
-
Google Places API failure is surfaced, not silently handled — If the Google Places API is unavailable, Sands does NOT fall back to distance heuristics. It surfaces a warning and asks for a manual estimate.
-
Undo window is 24 hours and non-recurring — Event undo is only available within 24 hours of the original action. Recurring event scope changes cannot be undone at all.
-
OAuth tokens may stale between cron runs — Calendar queries can fail with auth errors if the OAuth token expires between scheduled runs. Always trigger re-authentication before retrying; do not suppress the error.
- Compound failure: OAuth stale + MCP unreachable — When
get_eventsfails with an OAuth error, the corrective action isstart_google_auth. But if the MCP server is also unreachable,start_google_authwill fail too (same transport). In this scenario: (1) notedegraded: google_workspace_mcpANDdegraded: oauth_stalein evidence, (2) updateconfig.json auth_statustoSTALE_OAUTH, (3) surface to the user that TWO things need fixing — the MCP server process must be running AND OAuth must be re-authorized. Do NOT retry auth in a loop when the MCP server is unreachable; it will just burn tool calls.
- Compound failure: OAuth stale + MCP unreachable — When
-
Timezone offsets change with daylight saving — Pacific time is
-08:00(PST) in winter and-07:00(PDT) in summer. When building RFC3339 time_min/time_max for queries, determine the correct offset for the TARGET date, not today's date. Using the wrong offset shifts the query window by one hour and can return no events or wrong-day events. Thedefault_timezonein config.json (America/Los_Angeles) is a hint — always check whether the target date falls in PDT (Mar–Nov) or PST (Nov–Mar) and use the matching offset. -
Google Workspace MCP server may be transiently unreachable — If
get_eventsor other MCP calls fail with "unreachable" errors, wait ~40 seconds (the auto-retry cooldown) and try once more before loggingdegraded. A single cooldown wait resolves most transient failures. Only logdegraded: google_workspace_mcpafter the retry also fails. -
Single event = no travel blocks needed — When only one event exists on a travel-check day, there are nothing to insert between. Still write evidence (with
not_activity_reason: no_consecutive_events) and updateconfig.json last_travel_checkso gap detection stops flagging the stale timestamp. If the single event is all-day (no timed events at all), usenot_activity_reason: no_timed_events— this distinguishes "nothing to check" from "one event, nothing between." -
Overlapping events = no travel blocks, but flag conflict — When consecutive timed events overlap (event B starts before event A ends),
gap_minuteswill be negative. Usenot_activity_reason: events_overlap_no_gapin the evidence log. Also flag the overlap in theoverlap_detectedfield so the evening brief and conflict scan can reference it. Do NOT create a travel block for overlapping events — they have no gap to fill. -
Google Places API key empty = travel check is observational — When
google_places_api_keyis empty in config.json, travel blocks can never be auto-created. The travel-check command runs but will only report consecutive event pairs it cannot service. If the key is empty, note this in the evidence log'sdegradedfield. -
MCP Google Workspace tools may fail with auth errors even when the server is running — The
mcp_google_workspace_get_eventsand related MCP tools can return OAuth errors (401/403,invalid_grant) even when the MCP server process is reachable. When ANY MCP calendar call fails with an auth error, switch to the direct Python fallback:from google_auth import get_calendar_servicefrom<hermes-home>/scripts/google_auth.pyand call the Calendar API v3 directly. The direct fallback uses the same credential store (<gworkspace-creds>/credentials/) and often succeeds when MCP fails because it bypasses the MCP server's token management layer. Seereferences/direct_calendar_access.mdfor the working pattern. -
Reference files may be empty —
references/briefing_windows.md,references/vesper_emit_format.md, andreferences/preparation_signals.mdare currently 0 bytes. Do not block on reading them; proceed with the defaults documented in this SKILL.md (morning brief = today's events, evening brief = tomorrow's events, both inAmerica/Los_Angeles). -
Calendar IDs can 404 — If a configured
primary_calendar_idreturns 404 (not found), log it indegradedand continue with the remaining calendars. Surface the broken calendar ID to the user so they can updateconfig.json. Do not let one broken calendar block the entire query. -
execute_code is blocked in cron mode — Cron jobs run without a user present to approve
execute_code, so it will be rejected. When Sands needs to run Python analysis scripts (conflict detection, travel analysis, etc.) from a cron job, use thewrite_file+terminalpattern instead: (1)write_filethe script to a temp path like/tmp/sands_analysis.py, (2)terminal("python3 /tmp/sands_analysis.py")to run it, (3) read results from stdout or a temp JSON output file. Seereferences/direct_calendar_access.mdfor the full cron-compatible pattern. For conflict scans, you can copy and adapt the reusable template atscripts/conflict_scan_template.py. -
Cross-calendar duplicates are conflicts — When the same event appears on multiple calendars (detected by matching summary + start time + location), flag it as a DUPLICATE conflict. Timezone offset differences between calendars can make the same event appear at different UTC times — normalize to local time before comparing. Recommend removing the duplicate from the non-canonical calendar.
-
Events crossing midnight UTC may belong to the previous local day — When querying with UTC-based time windows, an event starting at
2026-06-07T01:00:00Zis actually2026-06-06T18:00:00-07:00(6 PM PDT on June 6). Always convert event start times to local timezone (America/Los_Angeles) before assigning to a date. ThesingleEvents=Trueparameter in the Calendar API expands recurring events but does not normalize timezones — the response preserves the event's original timezone, which may differ from the query window timezone. -
MCP tools require
user_google_emailon every call — Everymcp_google_workspace_*tool requires auser_google_emailparameter (the authenticated user's Google email). Omitting it produces a Pydantic validation error that doesn't clearly say "missing parameter." Always includeuser_google_email— use the agent's own email (e.g.,<agent-email>) unless the user specifies otherwise. This applies to ALL MCP Google Workspace tools, not justget_events. -
Zero-duration events are warnings, not conflicts — Events where
start == enddo not overlap with anything and should not be flagged as conflicts. Instead, flag them aszero_durationwarnings in the report. These are almost always data quality issues (end time not set correctly). Critical code-level trap:span_minutes()'s midnight-crossing guard (if e <= s: e += 1440) expands a12:45 -> 12:45event into a 24-hour span, which fabricates overlaps with every later event AND pollutes free-hours math. Therefore BOTH conflict detection and free-hours computation MUST exclude zero-duration events BEFORE callingspan_minutes(computezero_duration = is_timed and start == endat parse time, then compare only non-zero-duration events and skip them incalc_free_hours). This bug was live intemplates/sands_briefing_morning.pyuntil 2026-07-23 and produced a false 120-min "Appointment overlaps Gym" conflict. Seereferences/zero_duration_briefing.mdandreferences/conflict_detection.md. -
⚠️ Midnight-crossing events break naive conflict & free-hour math — An event ending at
00:00(e.g.19:30→00:00) parsed by naiveHH:MM→minutes yields a negative overlap (0 - 1170) that hides a real conflict. Always treatend_min <= start_minas crossing midnight (add1440to end) before computing overlaps or busy spans. Found and fixed 2026-07-22 intemplates/sands_briefing_morning.py(conflict loop +calc_free_hours); the fix usesspan_minutes(start, end).scripts/conflict_scan_template.pyuses UTC-aware overlap detection so it is NOT affected — but any new HH:MM-minute-based overlap code must apply the same guard. -
400 Bad Requestfrom oauth2.googleapis.com = dead credentials — Besidesinvalid_grant, expired/revoked refresh tokens can return HTTP400from the token endpoint. Surfaces as"400 Client Error: Bad Request for url: https://oauth2.googleapis.com/token"fromget_service(). Treat identically toinvalid_grant: log, move to next account. Do NOT interpret400as a bug in your code. -
⚠️ Interactive command access — Sands commands like
sands.logistics.travelare not direct shell commands. They are accessed through the skill's interactive menu. Invoke the skill with/command (or your interface's equivalent) to see the two-level menu, then navigate to the desired command (e.g., Travel Check → Check next day events for missing travel blocks). Direct shell invocation of sands commands will not work and will produce "command not found" errors. -
config.jsonauth_statuscan be stale after fallback — When the primary account's token is dead but the fallback account succeeds,auth_statusmay still sayMCP_ONLYorSTALE_OAUTH. After a successful direct-Python fallback, updateauth_statustoOKso the next run doesn't pre-emptively assume degradation. The field reflects the system's ability to reach the calendar, not any single account's token state. -
Journal directory may not exist on first cron run — The
## Initializationstep 4 says to create{agent_root}/commons/journals/ocas-sands/, but journal writes have been observed to land at{data_dir}/journals/(i.e., the same parent asconfig.json). Before callingappend_jsonl.pyfor journals, ensure the directory exists:mkdir -p "$DATA_DIR/journals". If the append fails withFileNotFoundErroron the parent, create it and retry. The script itself only handles file-level existence, not directory creation. -
Evening-brief template now exists —
templates/sands_briefing_evening.pymirrors the morning template but targets TOMORROW, omits prep-signal checks, emitsproposal_type: routine_prediction, and excludes zero-duration events from conflict/free-hours math. The morning template previously had the zero-duration false-conflict bug (see below); the evening template was written correct from the start and the morning template was patched to match. When generating evening briefs, run the template (pure generator) then persist: query all primary calendars with PDT-correct offsets, sort by start time, check overlaps/duplicates within the target date, build the Vesper InsightProposal payload (brief_type: evening,proposal_type: routine_prediction), render the report, write evidence withcommand: sands.briefing.generate, write to action journal (briefing.generate is an Action Journal command), updateconfig.json last_evening_brief. Persistence recipe inreferences/zero_duration_briefing.md.
Cron Script Templates
templates/sands_briefing_morning.py— Reusable cron-compatible morning briefing script with multi-account fallback, dedup, conflict detection (zero-duration-safe), and prep-signal checking.templates/sands_briefing_evening.py— Evening counterpart: queries TOMORROW's events, omits prep-signal checks, emitsproposal_type: routine_prediction, and excludes zero-duration events from conflict/free-hours math. Pure generator; the calling run persists evidence/action/config (seereferences/zero_duration_briefing.md).
Support File Map
| File | When to read |
|---|---|
references/briefing_windows.md | Before sands.briefing.generate |
references/calendar_config.md | Before configuring calendars or timezone handling |
references/credential-files.md | Before first OAuth setup or when handling token staleness |
references/timezone_handling.md | Before constructing time_min/time_max for get_events |
references/google_calendar_api_quirks.md | Before manage_event calls |
references/duration_defaults.md | Before sands.event.create |
references/flexibility_rules.md | Before sands.schedule.conflicts |
references/conflict_detection.md | Before conflict analysis |
references/conflict-report-format.md | Before generating conflict scan report output |
references/recurring_events.md | Before creating/modifying/deleting recurring events |
references/preparation_signals.md | Before sands.briefing.generate |
references/travel_time_logic.md | Before sands.logistics.travel |
references/vesper_emit_format.md | Before sands.briefing.generate; formatting payload for Vesper |
references/self-update-sands.md | Before running sands.update |
references/direct_calendar_access.md | When MCP Google Workspace tools are unavailable; direct Python fallback pattern |
scripts/conflict_scan_template.py | Reusable cron-compatible conflict scan script with multi-account OAuth fallback |
references/chronicle_sync.md | Before sands.chronicle.sync; event classification rules, value format, ingest script path, cron auth pattern, classification pitfalls |
references/gotchas.md | Common pitfalls, OAuth quirks, MCP tool limitations, cron-mode constraints, and UCSF MyChart double-import pattern |
references/cron_persistence.md | Unicode-safe JSONL persistence in cron mode (emoji in titles) + why config.json primary_calendar_ids drifts from the briefing template's hardcoded calendar list |
templates/sands_briefing_morning.py | Reusable cron-compatible morning briefing script — multi-account fallback, dedup, conflict detection, prep signals |
references/mcp_fallback_briefing.md | When encountering dependency errors with the morning briefing script |
What ships with it: 39 files
401.1 KB alongside SKILL.md, 5 of them executable
assets/
- readme/hero.jpg258.6 KB
evals/
- evals.json729 B
references/
- briefing_windows.md260 B
- calendar_config.md1.7 KB
- chronicle_sync.md6.6 KB
- conflict_detection.md7.7 KB
- conflict-report-format.md2.9 KB
- credential-files.md1.0 KB
- cron_persistence.md3.1 KB
- direct_calendar_access.md8.8 KB
- duration_defaults.md1.5 KB
- flexibility_rules.md4.9 KB
- google_calendar_api.md3.4 KB
- google_calendar_api_quirks.md1.5 KB
- gotchas.md14.2 KB
- interactive-menu.md1.9 KB
- known-calendar-ids.md2.5 KB
- mcp_fallback_briefing.md4.0 KB
- oauth_recovery.md3.1 KB
- okrs.md288 B
- preparation_signals.md1.5 KB
- recurring_events.md889 B
- schemas.md2.5 KB
- self-update-sands.md825 B
- timezone_handling.md2.2 KB
- travel_time_logic.md4.1 KB
- vesper_emit_format.md3.6 KB
- zero_duration_briefing.md2.6 KB
scripts/
- append_jsonl.pyruns1.3 KB
- conflict_scan_template.pyruns14.3 KB
- update.shruns117 B
templates/
- sands_briefing_evening.pyruns14.2 KB
- sands_briefing_morning.pyruns15.0 KB
- CHANGELOG.md3.4 KB
- config.yaml24 B
- evals.json729 B
- .gitignore77 B
- LICENSE1.0 KB
- README.md4.0 KB