lowercase-hyphenated and equal to its directory, which is what both harnesses require to LOAD it. carrying a human title there meant the skill was listed to an agent and then failed to open. the prose moves to `title`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
7.3 KiB
Markdown
153 lines
7.3 KiB
Markdown
---
|
|
name: calendar-and-contacts
|
|
title: Calendar and contacts
|
|
description: Use when the owner asks you to add, move, cancel or look up an event, or to add, remove or find a contact — "dinner Saturday with my wife", "move that to 9", "when am I seeing the CEO", "add Peter Griffin, this number". Carries the JSON routes, the one credential, and the timezone trap that puts every event an hour out.
|
|
version: 2
|
|
---
|
|
|
|
You can read and write the owner's calendars and address books. **It is a JSON API and you already have
|
|
the key.** Nothing to ask for, no password to be given, no XML.
|
|
|
|
You do not write iCalendar or vCard by hand. You did once, and the recipes were long because the format
|
|
is full of things that fail silently — escaping, 75-octet line folding, an end date that is exclusive,
|
|
a timezone definition with dates of its own. All of that now happens on the server. Send fields.
|
|
|
|
## The one door
|
|
|
|
```bash
|
|
OFFICER=(-H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST"
|
|
-H 'content-type: application/json')
|
|
curl -s "${OFFICER[@]}" "$OFFICER_API_URL/api/caldav/_officer/collections"
|
|
```
|
|
|
|
Your key is bound to ONE account — the owner's. You cannot reach anyone else's calendar with it and you
|
|
do not need to guard against doing so: another user's path answers 400. Write the owner's own paths and
|
|
stop thinking about it.
|
|
|
|
## Find the collections first — never guess a path
|
|
|
|
```bash
|
|
# {"collections":[{"path":"/dav/1/personal/","name":"personal","displayName":"Personal","kind":"calendar"},
|
|
# {"path":"/dav/1/contacts/","name":"contacts","displayName":"Contacts","kind":"addressbook"}]}
|
|
```
|
|
|
|
`kind` is `calendar` or `addressbook`. Pass the **`path`** to every route below. There are usually
|
|
several calendars — **"Personal" is the default for anything the owner does not place** — and Tasks holds
|
|
VTODOs, which show up in Reminders on a phone.
|
|
|
|
## THE TIMEZONE TRAP — read this before writing any time
|
|
|
|
The owner says "Saturday at 8". The API takes UTC. **`date -u -d` does not convert** — `-u` makes GNU
|
|
date parse the input as UTC *and* print it as UTC, so "8pm" becomes 20:00Z, which is 21:00 in Lisbon.
|
|
Every event lands an hour out and nothing errors.
|
|
|
|
```bash
|
|
# WRONG — prints 2026-08-29T20:00:00Z
|
|
date -u -d 'saturday 20:00' +%Y-%m-%dT%H:%M:%SZ
|
|
# RIGHT — prints 2026-08-29T19:00:00Z, via epoch so the local zone is applied
|
|
date -u -d "@$(date -d 'saturday 20:00' +%s)" +%Y-%m-%dT%H:%M:%SZ
|
|
```
|
|
|
|
The machine's clock is the owner's timezone. Parse what they said with plain `date -d`, convert through
|
|
`+%s`, and **say the LOCAL time back to them** when you confirm — never the UTC you stored.
|
|
|
|
## Look something up
|
|
|
|
```bash
|
|
curl -s "${OFFICER[@]}" "$OFFICER_API_URL/api/caldav/_officer/events?collection=%2Fdav%2F1%2Fpersonal%2F"
|
|
# {"events":[{"uid":"…","href":"/dav/1/personal/….ics","summary":"Dinner with my wife",
|
|
# "start":"2026-08-29T19:00:00Z","location":"Cervejaria Ramiro","allDay":false}]}
|
|
```
|
|
|
|
Contacts are the same shape at `_officer/contacts`, returning `fullName`, `emails` and `phones`.
|
|
|
|
**Search every calendar, not just Personal** — the answer is often in another one. `start` and `end` are
|
|
UTC, so convert before speaking: `date -d '2026-08-29T19:00:00Z' '+%A %-d %B, %H:%M'`.
|
|
|
|
`rrule` present means the event REPEATS and the listed `start` is the series start, not the next
|
|
occurrence. Do not compute occurrences yourself — say it repeats and give the rule in words.
|
|
|
|
**`href` is how you change or delete a thing, and it only ever comes from a read.** Never build one. A
|
|
file is only conventionally named after its UID: everything a phone wrote is named something else, and a
|
|
constructed href finds nothing or, worse, finds something.
|
|
|
|
## Add an event
|
|
|
|
```bash
|
|
curl -s "${OFFICER[@]}" -X POST "$OFFICER_API_URL/api/caldav/_officer/event" \
|
|
-d '{"collection":"/dav/1/personal/","summary":"Dinner with my wife","location":"Cervejaria Ramiro",
|
|
"start":"2026-08-29T19:00:00Z","end":"2026-08-29T21:00:00Z","allDay":false}'
|
|
# {"href":"/dav/1/personal/<uuid>.ics"}
|
|
```
|
|
|
|
All-day takes plain dates and no times: `{"start":"2026-08-29","allDay":true}`. `end` is the LAST day as
|
|
a person means it — a two-day trip is `"start":"2026-08-29","end":"2026-08-30"`. No end means one hour,
|
|
or one day.
|
|
|
|
## Add a contact
|
|
|
|
```bash
|
|
curl -s "${OFFICER[@]}" -X POST "$OFFICER_API_URL/api/caldav/_officer/contact" \
|
|
-d '{"collection":"/dav/1/contacts/","fullName":"Peter Griffin",
|
|
"emails":["peter@quahog.example"],"phones":["+351912345678"],"org":"Pawtucket Brewery"}'
|
|
```
|
|
|
|
`fullName` is what a phone displays and is required. Phone numbers keep their international prefix.
|
|
|
|
## Change something
|
|
|
|
One PATCH, with only the fields that change. Everything else in the file is left exactly as it was —
|
|
including alarms, attendees, recurrence rules and anything a phone put there that Officer has no idea
|
|
about.
|
|
|
|
```bash
|
|
curl -s "${OFFICER[@]}" -X PATCH "$OFFICER_API_URL/api/caldav/_officer/item" \
|
|
-d '{"href":"/dav/1/personal/….ics","start":"2026-08-29T20:00:00Z","end":"2026-08-29T22:00:00Z"}'
|
|
# {"ok":true,"item":{…the whole thing, as it now reads…}}
|
|
```
|
|
|
|
- **Events**: `summary`, `location`, `description`, `start`, `end`, `allDay`.
|
|
- **Contacts**: `fullName`, `emails`, `phones`, `org`, `title`.
|
|
- `null` REMOVES a field. `""` leaves it there and empty. Omit it to leave it alone.
|
|
- `emails` and `phones` replace the whole list. To add a number, send the existing ones back with the new
|
|
one — read first.
|
|
|
|
**Moving an event is a PATCH, not a new event.** Creating a second one and leaving the first is the most
|
|
common way this goes wrong, and the owner finds out when two of them ring.
|
|
|
|
**Confirm from `item`, not from what you sent.** It is the file re-read after the write.
|
|
|
|
## Delete
|
|
|
|
```bash
|
|
curl -s "${OFFICER[@]}" -X DELETE "$OFFICER_API_URL/api/caldav/_officer/item" \
|
|
-d '{"href":"/dav/1/personal/….ics"}'
|
|
```
|
|
|
|
**Confirm before deleting when the match is not exact.** "Cancel dinner on Saturday" with one dinner on
|
|
Saturday is unambiguous — do it. Two dinners, or a recurring one, is a question: this cannot be undone
|
|
from here, and the owner discovers it when somebody is stood up.
|
|
|
|
Deleting one occurrence of a REPEATING event is not a delete — it needs an EXDATE on the series. Say so
|
|
rather than removing every occurrence.
|
|
|
|
## What this API deliberately cannot do
|
|
|
|
It writes ordinary events and ordinary cards. **Recurrence rules, alarms, attendees, EXDATEs and photos
|
|
are not in it** — not an oversight, but the same rule that makes a PATCH safe: a field the screens cannot
|
|
show is a field an editor should not invent.
|
|
|
|
When the owner genuinely needs one, the DAV door is still there — HTTP Basic, their email as the
|
|
username and `$OFFICER_AGENT_KEY` as the password, PUT the whole `.ics` to its href. Read the file first
|
|
with `_officer/item?href=…`, change what you must, and put it back with the same UID and the same
|
|
filename. **Bump `SEQUENCE`** so a phone reads an update rather than a duplicate. If you are doing this,
|
|
say so — it is the part where a mistake is silent.
|
|
|
|
## Confirm in one line, in their words
|
|
|
|
"Added — Dinner with my wife, Saturday 29 August, 20:00, Cervejaria Ramiro." Local time, the name they
|
|
used, and where. Not the href, not the UTC, not the HTTP status.
|
|
|
|
If a write fails, say what failed and stop. **Never retry a create after a failure** — the usual cause is
|
|
that the first one partly worked, and a retry leaves two.
|