the owner's scenario: any chat, "I'm having dinner Saturday with my wife, add that", "cancel it", "add Peter Griffin, this number" — and it happens. reading already worked through the JSON api. writing is the DAV door, and the credential question is what took the thinking: every option meant a NEW secret and then somewhere to keep it — a file, the secret store, one per commander — each a password to distribute, rotate and eventually leak. $OFFICER_AGENT_KEY is already in every agent's environment, already bound to one user, already revoked when the session ends. so the DAV door accepts it as the basic-auth password, and no new secret exists anywhere. it cannot reach another account, and that is structural rather than a check: resolveApiKey says whose the key is and that id becomes X-Remote-User. the URL is never consulted, radicale refuses any other principal, and the username in the header must AGREE with the key rather than select the account. verified — own principal 207/201, another account 403, spoofed username 401. the permission check is isApiRequestAllowed against this plugin's own prefix, so the answer is exactly what the gate would give the same credential at /api/caldav. a second copy of that rule is the copy that stops being updated. DAV methods are mapped to read/write with PROPFIND and REPORT as reads — unmapped they counted as writes and a member with read could not list their own calendar. anything unrecognised defaults to write, which is the safe direction. the skill is written from recipes that were RUN, not recalled: create 201, move 204 (same UID, same href, SEQUENCE bumped), delete 200, contact create and delete, and the json read for lookups. the trap it exists to prevent, found while testing rather than by reading: `date -u -d 'saturday 20:00'` does not convert. -u parses AND prints as UTC, so 8pm becomes 20:00Z, which is 21:00 in lisbon. every event an hour out, nothing errors, and it is invisible until someone is stood up. the two-step through epoch is the only form that works and the skill leads with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
156 lines
7.2 KiB
Markdown
156 lines
7.2 KiB
Markdown
---
|
|
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 filename must be `<UID>.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`.
|
|
|
|
## 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 `<UID>.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 '<D:href>[^<]*'
|
|
```
|
|
|
|
**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.
|