---
title: "Find a time with Alex: your assistant coordinates meetings across people and calendars"
series: Build It Yourself — briefs
brief: 07
license: CC BY 4.0
tested_on: Claude Code 2.1 (Oct 2026) · Node 25 · macOS 26 — steps 1–8 built and run by a fresh headless session (calendar replaced by fixtures and a local HTTP stand-in for the Google API, LLM replaced by canned and deliberately lying answers, chat transport written to log files, clock faked); step 9 (scheduler and chat wiring) written but not run
effort: one weekend (6 h) for slots, state and the guest gate; +2 h for the real calendar and chat wiring
---

# "Find a time with Alex" — and it just happens

## TL;DR
You type one sentence — *"find a time with Alex next week, 45 minutes"* — and the assistant
reads your calendar, picks three slots, offers them to Alex in chat, understands the answer,
sends the invite from **its own** calendar, and reminds everyone the day before. The
interesting part is not the happy path, which any demo can do. It is the seam between the LLM
(who understands "Thursday afternoon works, or Sunday?") and the code (which must own dates,
weekdays, retries, who-gets-told and how many messages a human receives). We ran this
conversation as two language models talking in prose and it produced thirty-three messages in
half an hour. This brief builds the version where the model only gets the judgment slice.

---

## What you get (a scene)

> **You:** "Find a time with Alex next week. 45 min, not before 10."
>
> **Assistant (to you, 20 seconds later):** "Offered Alex: Tue 14:00, Wed 11:30, Thu 16:00.
> I'll tell you when there's an answer."
>
> **Assistant (to Alex):** "Hi Alex, on behalf of Sam — would any of these work for a
> 45-minute call? Tue 14:00 · Wed 11:30 · Thu 16:00"
>
> **Alex:** "Thursday's good, but could it be earlier?"
>
> **Assistant (to you):** "Alex can do Thursday but wants earlier. Your Thursday is free from
> 09:30 — offer 10:00 or 11:00? [10:00] [11:00]"
>
> *(You tap one. Alex gets one message. The invite arrives. The day before, both of you get
> one line: "Tomorrow 10:00 — call with Alex".)*

Alex never sees your calendar. Alex sees three times, and later the one you agreed on. You,
in your own chat, see the full context.

---

## Prerequisites

- The always-on machine, the chat channel and `notify.sh` from **brief 00**
  (`start-here-always-on-agent.md`).
- **A calendar the assistant can read** (Google Calendar API, or any CalDAV/ICS feed). Read-only
  on yours is fine, and better: see trap 1.
- **A calendar of the assistant's own** (a free Google account named after it). Invites are
  sent *from* it, so it can always edit them.
- A way to message the guest: the same chat channel as brief 00, or email. Step 5 abstracts it
  behind one function.
- An LLM API key or a logged-in Claude Code (the model is used in two small places, see below).

---

## Architecture (the 15-line version)

```
 you ──"find a time with Alex"──▶ coord.js create  ──▶ meetings.json  (one record per meeting)
                                        │                 status: proposing → countered →
                                        ▼                         confirmed → done | cancelled
  calendar.js (read yours + assistant's own)
        │ busy blocks only
        ▼
  slots.js  PURE: busy[] + hours + duration + buffers + tz → ranked free slots
        │
        ▼
  render.js  code writes "Thu 16.10 16:00" (weekday computed, never typed by a model)
        │
        ▼
  guest-gate.js  ONE function every guest message passes: cap, spacing, quiet hours, redaction
        │                                   ▲
        ▼                                   │ reply text
      guest ───────────────▶ interpret.js (LLM picks ONE of the offered slots, or "none/other")
                                        │ code validates the pick is really in the offer
                                        ▼
                    invite.js  (create on the assistant's calendar) → reminders.js (D-1)
```

1. **Code owns the plumbing; the model owns the reading of a human sentence.** Dates, retries,
   the message cap and "who do we tell" are never prompts.
2. **A guest only ever sees free/busy-derived times.** Event titles, attendees and locations
   of your calendar never leave `calendar.js` — it returns blocks, not events.
3. **One record per meeting**, including meetings someone just told you the time of — a known
   time still needs reminders (trap 4).
4. **Every outbound guest message goes through one gate.**

---

## Build steps — paste each block into Claude Code, in order

### Step 1 — the calendar adapter that returns blocks, not events

```text
Create ~/agent/coord (Node 20+, no framework). config.json: {"tz": "Europe/London",
"calendars": ["primary"], "own_calendar": "assistant@example.com", "work_hours":
{"mon-thu": ["09:00","18:00"], "fri": ["09:00","13:00"]}, "buffer_min": 15,
"min_notice_h": 12, "horizon_days": 21}.
- calendar.js is the ONLY file that talks to the calendar. Export `busy(fromISO, toISO)` →
  [{start, end}] — merged, sorted, UTC ISO. It must NEVER return titles, attendees or
  locations: strip everything but times at the boundary. Treat all-day events as busy only if
  they are marked "busy" (not "free"), and tentative events as busy.
- Implement two backends behind the same function: google.js (Calendar API freebusy
  endpoint, token read from a 0600 file or the OS secret store — never inline) and
  stub.js, selected by `--stub`, which reads fixtures/busy.json (three weeks of plausible
  blocks, including an overnight block, an all-day block and two overlapping events).
- `node calendar.js --stub 2026-10-26 2026-10-30` prints the merged busy blocks.
```
**Check:** the output has no key except `start` and `end`; the two overlapping fixtures come
out as one block; the all-day "free" fixture is absent.

### Step 2 — slots, as a pure function

```text
Create ~/agent/coord/slots.js: a PURE function
findSlots({busy, now, durationMin, config, prefer, notBefore, limit=3}) → [{start,end,score}].
No network, no clock reads (now is a parameter), no LLM.
Rules: only inside work_hours in config.tz (use Intl with the IANA zone — never a fixed
UTC offset, daylight saving moves it); leave buffer_min before and after every busy block;
respect min_notice_h from `now` and horizon_days; start times on :00/:15/:30/:45 only;
`notBefore` is a local time-of-day floor ("not before 10:00"); spread the results over
DIFFERENT days when possible (never three slots on one afternoon); score earlier-in-week
and mid-morning higher, but a slot adjacent to a busy block loses points.
Add `node --test` unit tests with fixture busy lists: an empty week, a full day, a day with a
gap exactly equal to duration+2*buffer (must fit) and one minute shorter (must not), a
request across a daylight-saving change, and a Friday short day.
```
**Check:** `node --test` passes; `node slots.js --stub --duration 45` prints three slots on
three different days, none overlapping a buffer.

### Step 3 — dates and weekdays are rendered by code

```text
Create ~/agent/coord/render.js with: `fmt(startISO, tz, lang)` → "Thu 16.10 16:00" (day name
from Intl, date d.m, 24-hour time), `fmtList(slots)` → "Tue 14:00 · Wed 11:30 · Thu 16:00"
grouped by date, and `weekdayGuard(text, tz, now)`: find every "d.m" / "d/m" / "d.m.yyyy"
that sits next to a weekday name or abbreviation in `text` and check the pair against the real
calendar (a date without a year means the next occurrence from `now`). Return
{ok:true} or {ok:false, errors:[{found, expected}]}. Every string that goes to a human and
contains a date must be built with fmt() and ALSO pass weekdayGuard() before it is sent; a
failure throws with the corrected text in the message. Unit tests: a correct pair, an
off-by-one weekday, a year boundary, and "tomorrow" (no date → ignored).
```
**Check:** `weekdayGuard("see you Mon 16.10", "Europe/London", <fake now 2026-10-12>)`
reports the mismatch and proposes Fri 16.10.

### Step 4 — the meeting record and its state machine

```text
Create ~/agent/coord/store.js and coord.js. store.js: meetings.json written via temp file +
rename, each record {id, with: {name, contact}, requested_by, durationMin, status, offered:
[{start,end}], chosen, event_id, told: {startISO}, history: [], created_at}. Statuses and
the ONLY legal moves: proposing → countered | confirmed | cancelled; countered → proposing |
confirmed | cancelled; confirmed → done | cancelled. Any other move throws.
coord.js CLI (all deterministic, no LLM): `create --with Alex --contact <id> --duration 45
--not-before 10:00`, `offer <id>` (calls calendar → findSlots → stores `offered` → queues
ONE guest message through the gate from step 5), `answer <id> <slotIndex|none> [--note ..]`,
`confirm <id>`, `cancel <id>`, `list`. `create` is idempotent: same {contact, durationMin}
within 24 h with status proposing/countered returns the existing id instead of a second
record. Every transition appends to `history` with a timestamp. Add tests, including the
idempotent create and an illegal transition.
```
**Check:** run `create` twice in a row — one record; try `confirm` on a cancelled one — an
error that names the legal moves.

### Step 5 — the guest gate (this is the step that keeps you out of trouble)

```text
Create ~/agent/coord/guest-gate.js: the ONLY way anything is sent to a guest. Export
`send({meetingId, kind, text})` where kind ∈ offer | confirm | change | reminder | thanks | answer.
Rules in code, not prompts:
1. Redaction: text may contain only times from fmt(), the guest's own name, the requester's
   first name and plain words. Reject (throw "rephrase") if it matches the list in
   config.banned: titles of any calendar event, other attendees' names, "calendar", "busy",
   "rate limit", "ledger", "token", "quota", "message id", or an e-mail/phone number that is
   not the guest's own.
2. Cap: at most ONE automatic message per guest per meeting per local day. A second is HELD
   in held.json and merged into the next allowed send as one message — except kind=answer to
   a question the guest just asked, which always goes (and counts).
3. Spacing: nothing within 90 minutes of the guest's last message to us unless it IS the
   answer to it; no sends between 21:30 and 08:00 local — defer to 08:00 (not_before).
4. Deferral is stored, not narrated: a blocked send is written to outbox.jsonl with
   not_before, and `node outbox.js flush` (run every minute by the scheduler) sends what is
   due. Sending marks the entry done only AFTER the transport returns a message id; log the
   id. The model never creates a reminder to "try again later".
5. weekdayGuard() on every text.
Transport: one function `transport(contact, text)` that calls ~/agent/notify.sh-style chat
send from brief 00 (under --stub it appends to sent.log instead).
Tests: banned word rejected, second send held and merged, 22:00 send deferred to 08:00, a
crash between "decide to send" and "transport returned" re-sends nothing twice (state is
written before the call, with status "sending").
```
**Check:** `node --test` green; run `offer` twice for one meeting — `sent.log` has one line
and `held.json` has the second.

### Step 6 — the model reads the reply; code checks the answer

```text
Create ~/agent/coord/interpret.js: `interpret(meeting, replyText)` → one of
{kind:"pick", index} | {kind:"counter", wish:"earlier|later|other day|free text"} |
{kind:"decline"} | {kind:"question", text} | {kind:"unclear"}.
The model call lives behind ONE function llm(prompt) (swap-able; under --stub it returns
canned answers). The prompt contains only the offered slots (rendered by fmt()), the guest's
reply, and the instruction to answer with strict JSON. After the model answers, VALIDATE in
code: a "pick" index must exist in meeting.offered; if the reply contains a time or weekday
that is not in the offer, downgrade the result to "counter" no matter what the model said;
if JSON does not parse, return "unclear". "unclear" never triggers a send — it creates a
card for the requester ("Alex wrote: …  — which slot?") through notify.sh with buttons or
plain numbered replies.
Wire it: an inbound guest message → interpret → coord.js answer. pick → confirm → invite
(step 7); counter → recompute slots with the new wish and `offer` again (the gate decides
if it can go today); decline → ask the requester what to do.
Tests with canned replies: "Thursday is great", "can't do any, how about Friday morning?",
"sure!" (ambiguous between three slots → unclear), and a reply naming a time we never
offered (must NOT confirm).
```
**Check:** the "sure!" reply produces a card to you, not a confirmation; the never-offered
time becomes a counter-offer.

### Step 7 — the invite comes from the assistant's own calendar

```text
Create ~/agent/coord/invite.js: `create(meeting)` inserts the event on config.own_calendar
(NOT the requester's), title "<Requester first name> / <Guest name>", the guest's e-mail as
attendee if we have one, sendUpdates=all, and stores event_id in the meeting. Before ANY
edit or delete, fetch the event and check organizer.email === own_calendar; if not, refuse
and tell the requester what to do by hand — we are read-only on theirs. Idempotency: before
creating, search own_calendar (same day, +/- 2 h) for an event with the same attendee
or title; if found, ADOPT it (store its id) instead of inserting a duplicate. A move is a
patch of the existing event plus a `change` message through the gate — never delete and
recreate. Under --stub, write to fixtures/own-calendar.json.
```
**Check:** `confirm` twice creates one event; moving the meeting patches it; trying to edit
an event organised by someone else is refused with a clear message.

### Step 8 — reminders, and "a known time is still a meeting"

```text
Create ~/agent/coord/reminders.js, run daily at 08:15 and also from `coord.js create
--at <iso>` for meetings whose time is already known ("put lunch with Alex on Thursday 13:00"
— no negotiation, straight to confirmed). Pass A: scan own_calendar for the next 14 days and
REGISTER any event we organise that has no meeting record (so a meeting added by hand or by
another tool is not invisible). Pass B: for every confirmed meeting that starts tomorrow, send
ONE day-before line to the requester and the guest through the gate (kind=reminder, text
built from fmt()). An attendee who has not RSVP'd is treated as COMING — responseStatus never
gates a reminder. Never ask the requester to RSVP to their own meeting. If the guest is the
only one with a stale time (their `told.startISO` differs from the meeting's current start),
do not message them: send the requester ONE card "Alex still has Thu 16:00 — tell them about
the move?" and act only on yes. `--dry` prints the plan.
```
**Check:** `node reminders.js --dry --at 2026-10-14T08:15` lists exactly the meetings
starting on the 15th; a hand-made event in the fixtures gets a record; a rescheduled meeting
with a stale guest produces a card, not a message.

### Step 9 — the schedule and the chat entry point (written, not run)

```text
Wire the real thing: (a) schedule outbox.js flush every minute, reminders.js daily 08:15, as
launchd plists (macOS) or systemd timers (Linux), logging one line per run to
~/agent/coord/run.log; (b) teach the chat bridge from brief 00 two things: a message from the
requester starting with "find a time with …" calls `coord.js create` and `offer`; any inbound
message from a contact that has an open meeting goes to `interpret.js` first, and everything
else goes to the normal assistant. Show me the diff before applying it.
```
**Check:** `run.log` gains a line per run; a real chat sentence to your assistant produces
the offer to the guest within a minute.

---

## The traps we hit (read these twice)

**1. "I have no edit permission" — said three times, to the wrong person.** We had given the
assistant read-only access to the owners' calendars on purpose, then asked it to postpone a
meeting. It apologised for lacking permission and lectured about sharing settings, while the
invite it had *itself* sent sat on its own calendar, fully editable. *Cause:* two calendars,
no rule for which one owns an event. *Fix:* invites always live on the assistant's own
calendar; check `organizer.email` before any edit; search the assistant's calendar first.

**2. Two events with the same name.** The owner's private entry and the assistant's invite
for the same meeting both existed, and the wrong one was moved — on the wrong date. *Fix:* the
organizer check from trap 1, plus the meeting record holds the event id so nothing is ever
found by title.

**3. The weekday was typed by a model.** Invites went out with the wrong day name, and when
the guest said so, the guest-facing model — whose prompt had no date in it at all — argued
that today was a different day. *Fix:* no weekday or date in any outbound text that did not
come from `fmt()`, a guard that rejects a mismatched pair with the corrected text, a "now"
header on every model turn, and one prompt rule: never argue about dates, say you will check.

**4. A known time does not look like a meeting.** Someone forwarded a booking and said "put
it in the calendar for the three of us". The assistant answered "added ✅" 33 seconds later
with one bare calendar insert. No record, so no day-before reminder, and three people turned
up unreminded — twice in four days. *Fix:* step 8 pass A: every event the assistant organises
is registered, however it got there.

**5. Unconfirmed means coming.** Our first reminder logic skipped guests who had not accepted.
The two mistakes are not symmetric: reminding a no-show wastes a line, missing a yes-show
loses the meeting. *Fix:* RSVP status never gates a reminder. And never nudge the owner for
RSVPs — nobody RSVPs to an invite they asked for, so it would fire on every meeting forever.

**6. Thirty-three messages in thirty minutes.** One four-person dinner, handled by two models
talking in prose, flooded the owner: sixteen were relays full of internal words ("message id",
"ledger", "rate limit"). *Cause:* status was chosen as conversation, by judgment, per message.
*Fix:* one gate (step 5), a banned-words list, reports that go to whoever *requested* the
task (ours were hard-wired to one person), and a quiet window after each relay.

**7. The retry nobody owned.** Our message cap was spent on small talk, so a real reply to a
guest was refused. The model was supposed to set a reminder to retry; its first attempt
returned nothing, the second targeted an address that could not be routed, and it died three
times — silently. The guest waited days. *Fix:* a refused send is written to an outbox with
`not_before`; the model never owns a retry, and the tool says "queued until 08:00", which is
the only thing it may repeat.

**8. We blamed the platform and it was our own brake.** When the cap hit, the assistant
told the owner the chat platform was limiting it. It was our own setting. *Fix:* the error text
the model receives names the real cause ("our own cap, resets at HH:MM"). A model that is
handed a vague error will invent a plausible one.

**9. A stale date held by a guest.** A meeting moved, and a guest held the old day for a week.
Auto-correcting felt right; the owner disagreed — a message to a person about a plan they did
not know had changed should be a decision. *Fix:* step 8 sends the owner ONE card instead, and
we added "one automatic message per guest per meeting per day", extras merged.

**10. Four messages about one change.** A time change produced a drift question per guest, then
a relay, then the chat model re-telling it — four lines for one fact. *Fix:* one ask per
meeting, and a header on the guest-facing model's turn saying the question was already asked.

**11. Another tool inserted a twin.** A booking tool re-booked a class the assistant had
already planned, giving two events for one thing and two reminders. *Fix:* invite creation
adopts a matching event on the same day instead of inserting (step 7) and a re-created twin
inherits the old answers.

**12. Denying being an assistant.** Pressed by a guest ("are you a bot?"), the assistant said
it was not. *Fix:* one prompt line the model may not override: "I'm Sam's digital assistant."

**13. Our own spacing rule delayed the confirmation.** The test build followed the gate rules
literally: the guest picked a slot, that counted as an inbound message, and the 90-minute
spacing rule then held our "confirmed, invite on its way" for an hour and a half. *Fix:* the
spacing rule applies to proactive messages only; an answer or a confirmation of what the guest
just chose always goes at once.

**14. "At most once" has a price.** Writing `sending` before the transport call guarantees a
crash cannot double-send — and also means a crash before the send leaves a message that is never
sent. We chose at-most-once on purpose (a duplicate to a person is worse than a late line), so
the stuck entry becomes `unconfirmed` and is shown to the requester instead of vanishing.

**15. A rejected reminder is not retried tomorrow.** If the gate rejects a day-before text (a
guest whose name contains a banned word, for instance), the run fails once — and the meeting
is no longer "tomorrow" the next morning, so nothing ever tries again. *Fix:* log it, tell the
requester, and let a human or a rewritten text send it the same day.

---

## You know it works when

- [ ] A calendar fixture with an all-day "free" block and two overlapping events gives the
      slots you expect, and no title ever appears in a guest message.
- [ ] A reply naming a time we never offered cannot confirm a meeting.
- [ ] Two `offer` calls in one day send one message; the second is held.
- [ ] A message queued at 22:00 goes out at 08:00 and is logged with a real message id.
- [ ] `weekdayGuard` rejects an off-by-one weekday before the guest sees it.
- [ ] A hand-made event on the assistant's calendar gets a record and a day-before reminder.
- [ ] Moving a meeting patches the same event; the guest gets exactly one `change` line.

## Extend it

- **Several guests.** Offer the same three slots to everyone, confirm when all (or a quorum
  you choose) pick one, and roll the answers up into one line for the requester.
- **Travel time.** Add a buffer from the event's location to the next one.
- **Video link.** Create a meeting link with the invite when the guest is remote.
- **Time zones.** Offer slots to a guest in another zone rendered in *their* zone, stored in
  yours.
- **Hosting.** For meetings at your place, add the address the day before, and a headcount
  line to your evening summary.

## Changelog

- 2026-10-20 — first public version. Tested on the versions in the header.

---

*Part of **Build It Yourself** — free briefs on building a personal AI Chief of Staff, one
capability at a time: https://chief.natalicot.com/briefs. Free, forever. · License CC BY 4.0*
