--- name: 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", "cancel that", "when am I seeing the CEO", "add Peter Griffin, this number". Carries the working recipes, the two credentials, and the timezone trap that puts every event an hour out. version: 1 --- You can read and write the owner's calendars and address book directly. **Reading goes through Officer's API. Writing goes through the DAV door.** Both use credentials you already have; there is nothing to ask for and no password to be given. ## The two doors **Read — Officer's own API, with `$OFFICER_AGENT_KEY`.** Parsed JSON, no XML. ```bash curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ "$OFFICER_API_URL/api/caldav/_officer/collections" ``` **Write — the DAV door, HTTP Basic, the SAME key as the password.** ```bash ME=$(curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ "$OFFICER_API_URL/api/auth/me" | python3 -c 'import json,sys; print(json.load(sys.stdin)["email"])') AUTH="$ME:$OFFICER_AGENT_KEY" ``` 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 principal answers 403, and naming a different user in the Basic header answers 401. Write the owner's own paths and stop thinking about it. ## Find the collections first — never guess a path ```bash curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ "$OFFICER_API_URL/api/caldav/_officer/collections" # {"collections":[{"path":"/dav/1/personal/","displayName":"Personal","kind":"calendar", ...}, # {"path":"/dav/1/contacts/","displayName":"Contacts","kind":"addressbook"}]} ``` `kind` is `calendar` or `addressbook`. There are usually several calendars — **"Personal" is the default for anything the owner does not place**, and Tasks holds VTODOs that show up in Reminders on a phone. ## THE TIMEZONE TRAP — read this before writing any time The owner says "Saturday at 8". Calendars store UTC. **`date -u -d` does not convert** — `-u` makes GNU date parse the input as UTC *and* print UTC, so "8pm" becomes 20:00Z, which is 21:00 in Lisbon. Every event lands an hour out and nothing errors. ```bash # WRONG — prints 20260829T200000Z date -u -d 'saturday 20:00' +%Y%m%dT%H%M%SZ # RIGHT — prints 20260829T190000Z, 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. ## Create an event **The short way, for an ordinary event.** One POST, and the sidecar writes the file — it mints the UID and the filename, converts to UTC, escapes the text and folds any line over 75 octets. All the parts below that are easy to get subtly wrong. ```bash curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ -H 'content-type: application/json' -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/.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 — the exclusive DTEND is this route's problem, not yours. It writes a minimal VEVENT and nothing else. **Anything with a recurrence rule, an alarm or attendees has to be written by hand**, below. ## Create an event by hand The filename must be `.ics`, and iCalendar lines are **CRLF-terminated**. `DTSTAMP` is required. ```bash UID="officer-$(date +%s)-$RANDOM" S=$(date -u -d "@$(date -d 'saturday 20:00' +%s)" +%Y%m%dT%H%M%SZ) E=$(date -u -d "@$(date -d 'saturday 22:00' +%s)" +%Y%m%dT%H%M%SZ) printf "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Officer//agent//EN\r\nBEGIN:VEVENT\r\nUID:$UID\r\nDTSTAMP:$(date -u +%Y%m%dT%H%M%SZ)\r\nDTSTART:$S\r\nDTEND:$E\r\nSUMMARY:Dinner with my wife\r\nLOCATION:Cervejaria Ramiro\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" \ | curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X PUT \ -H 'Content-Type: text/calendar; charset=utf-8' --data-binary @- \ "$OFFICER_API_URL/dav/1/personal/$UID.ics" # 201 created ``` An **all-day** event uses dates, not times, and DTEND is the day AFTER: `DTSTART;VALUE=DATE:20260829` with `DTEND;VALUE=DATE:20260830`. Writing the same day produces a zero-length event that some clients simply do not draw. Two more that bite when writing by hand, and are why the JSON route above exists: a comma, semicolon or backslash inside a SUMMARY must be backslash-escaped or it splits the value, and a content line over 75 octets must be folded onto a continuation line starting with one space. ## Look something up "When am I having dinner with the CEO?" — read, filter, answer in local time. ```bash curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ "$OFFICER_API_URL/api/caldav/_officer/events?collection=%2Fdav%2F1%2Fpersonal%2F" # {"events":[{"uid":"…","summary":"Dinner with my wife","start":"2026-08-29T19:00:00Z","end":"…", # "location":"Cervejaria Ramiro","allDay":false}]} ``` `start` and `end` are UTC. **Convert before speaking**: `date -d '2026-08-29T19:00:00Z' '+%A %-d %B, %H:%M'`. Search every calendar, not just Personal — the answer is often in another one. `location` is where "where did I set it up" is answered from; if it is empty, say so rather than inventing somewhere. `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. ## Move an event **Same UID, same filename, new times.** A new UID creates a second event and leaves the first in place, which is the most common way this goes wrong. ```bash # fetch the current copy, so nothing else in it is lost curl -s -u "$AUTH" "$OFFICER_API_URL/dav/1/personal/$UID.ics" > /tmp/ev.ics # then PUT a version with new DTSTART/DTEND and SEQUENCE bumped by one ``` Bump `SEQUENCE` (add `SEQUENCE:1`, or increment what is there). Phones use it to tell an update from a duplicate. A successful update answers **204**, not 201 — 201 means you created something new, so check your filename. ## Delete an event, or a contact ```bash curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X DELETE "$OFFICER_API_URL/dav/1/personal/$UID.ics" # 200 ``` **Find the UID by reading first — never guess.** The file is normally `.ics`, and if that 404s, list the collection and match on href: ```bash curl -s -u "$AUTH" -X PROPFIND -H 'Depth: 1' "$OFFICER_API_URL/dav/1/personal/" | grep -o '[^<]*' ``` **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: deleting the wrong event cannot be undone from here, and the owner will discover it when someone is stood up. Deleting one occurrence of a REPEATING event is not a DELETE — it needs an EXDATE added to the series. If that is what they meant, say so rather than removing every occurrence. ## Add or remove a contact Same shape, `.vcf` in the addressbook collection. ```bash UID="officer-$(date +%s)-$RANDOM" printf "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:$UID\r\nFN:Peter Griffin\r\nN:Griffin;Peter;;;\r\nTEL;TYPE=CELL:+351912345678\r\nEMAIL;TYPE=INTERNET:peter@quahog.example\r\nEND:VCARD\r\n" \ | curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X PUT \ -H 'Content-Type: text/vcard; charset=utf-8' --data-binary @- \ "$OFFICER_API_URL/dav/1/contacts/$UID.vcf" ``` `FN` is what shows on a phone and is REQUIRED. `N` is `Family;Given;Middle;Prefix;Suffix` — fill what you know and leave the rest empty rather than omitting the line. Read contacts back with `_officer/contacts?collection=…`, which returns `fullName`, `emails` and `phones` already parsed. ## 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 UID, not the UTC, not the HTTP status. If a write fails, say what failed and stop. **Never retry a PUT with a new UID** — the usual cause of a failure is that the first one partly worked, and a retry leaves two.