be a goodfriend and remember your friends' birthdays. birthdays from contacts and the people you text show up right in claude code, with a one-tap happy…

be a goodfriend and remember your friends' birthdays.
A Claude Code mod. While Claude works, the spinner gives you heads-ups ("🎂 Leo's birthday is in 3 days"). On the day, a card pops up and one tap opens Messages with a happy birthday ready to send.
claude plugin marketplace add nicodunks/goodfriend && claude plugin install goodfriend@goodfriend
Restart Claude, then run /seed to add birthdays for the people you text most. macOS only, Claude Code 2.1.286+.
/seed: fill in birthdays for the people who matter/goodfriend: see everyone's birthday/goodfriend add Maya Chen 10/7: add one (or just tell Claude)Birthdays also come from Contacts. goodfriend sees who you talk to, never what you said. /seed asks Claude to rank those names; birthdays stay on your Mac.
MIT
hooks/register.tsx 296 lines1import type { Engine, Register } from 'claude-code'
2
3// Birthdays from Contacts, /seed (the people you've been texting) and /goodfriend add.
4
5type Bday = { name: string; month: number; day: number }
6
7const CONTACTS = `const C = Application('Contacts')
8const n = C.people.name(), b = C.people.birthDate()
9JSON.stringify(n.flatMap((x, i) => (b[i] ? [{ name: x, month: b[i].getMonth() + 1, day: b[i].getDate() }] : [])))`
10
11// Every conversation, most recent first: "member count|chat name|name~handle;name~handle;"
12const CHATS = `tell application "Messages"
13 set out to ""
14 repeat with c in (chats)
15 try
16 set ns to ""
17 repeat with p in (participants of c)
18 set ns to ns & (name of p) & "~" & (handle of p) & ";"
19 end repeat
20 set n to name of c
21 if n is missing value then set n to ""
22 set out to out & (count of participants of c) & "|" & n & "|" & ns & linefeed
23 end try
24 end repeat
25 return out
26end tell`
27
28const MONTHS = 'jan feb mar apr may jun jul aug sep oct nov dec'.split(' ')
29const DAY = 86_400_000
30const ENOUGH = "That's enough for now"
31const ACCENT = '#D97757' // Claude orange
32
33const first = (name: string) => name.split(' ')[0].toLowerCase()
34const any = <T,>(xs: T[]) => xs[Math.floor(Math.random() * xs.length)]
35const valid = (m: number, d: number): [number, number] | null => (m >= 1 && m <= 12 && d >= 1 && d <= 31 ? [m, d] : null)
36
37// "10/7", "oct 7", "October 7th", "7 Oct"
38function parseDate(s: string): [number, number] | null {
39 const num = /^\s*(\d{1,2})[/.-](\d{1,2})\s*$/.exec(s)
40 if (num) return valid(+num[1], +num[2])
41 const word = /([a-z]{3})[a-z]*\.?\s+(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)?\s+([a-z]{3})/i.exec(s)
42 if (!word) return null
43 const m = MONTHS.indexOf((word[1] ?? word[4]).toLowerCase()) + 1
44 return m ? valid(m, +(word[2] ?? word[3])) : null
45}
46
47type Candidate = { name: string; handle: string; recent: number; groups: string[] }
48type Chat = { size: number; title: string; members: { name: string; handle: string }[] }
49
50const realName = (n: string) => n && !/^[+\d(]/.test(n) && !n.includes('@')
51
52async function chats($: Engine): Promise<Chat[]> {
53 const r = await $.process.run(['osascript', '-e', CHATS], { timeoutMs: 120_000 })
54 return r.stdout.split('\n').filter(Boolean).map(row => {
55 const [size, title, members = ''] = row.split('|')
56 const people = members.split(';').filter(Boolean).map(m => m.split('~'))
57 return { size: +size, title, members: people.map(([n, handle]) => ({ name: n.replace(/^Maybe:\s*/, '').trim(), handle })) }
58 })
59}
60
61// everyone you talk to, with the signals that say how close you are
62async function candidates($: Engine): Promise<Candidate[]> {
63 const people = new Map<string, Candidate>()
64 for (const [i, chat] of (await chats($)).entries()) {
65 for (const { name, handle } of chat.members.filter(m => realName(m.name))) {
66 const p = people.get(name) ?? { name, handle, recent: Infinity, groups: [] }
67 if (chat.size === 1) p.recent = Math.min(p.recent, i + 1)
68 else p.groups.push(chat.title || 'unnamed group')
69 people.set(name, p)
70 }
71 }
72 return [...people.values()]
73}
74
75const named = (p: Candidate) => p.groups.filter(g => g !== 'unnamed group')
76
77// let Sonnet use judgment (family, close friends) over the raw signals; recency if it can't
78async function rank($: Engine, list: Candidate[], me: string): Promise<string[]> {
79 const top = list.sort((a, b) => a.recent - b.recent || b.groups.length - a.groups.length).slice(0, 80)
80 const facts = top.map(p =>
81 [p.name, p.recent < Infinity && `texted 1:1 (#${p.recent} most recent)`, p.groups.length && `in ${p.groups.length} group chats with me${named(p).length ? `: ${named(p).slice(0, 4).join(', ')}` : ''}`]
82 .filter(Boolean)
83 .join(' · '),
84 )
85 const prompt = `I'm ${me || 'the user'}. Pick the 10 people whose birthdays I'd most regret forgetting, most important first. Family comes first (Mom, Dad, grandparents, siblings, anyone sharing my last name or in family group chats), then the friends I talk to most. Skip businesses and bots.
86
87${facts.join('\n')}
88
89Reply with only a JSON array of names, exactly as written above.`
90 const r = await $.model.complete({ model: 'claude-sonnet-5-5', prompt, maxTokens: 400, timeoutMs: 60_000 })
91 const names = new Set(top.map(p => p.name))
92 try {
93 if (r.isAnswered) return (JSON.parse(/\[[\s\S]*\]/.exec(r.text)![0]) as string[]).filter(n => names.has(n)).slice(0, 10)
94 } catch {}
95 return top.slice(0, 10).map(p => p.name)
96}
97
98async function load($: Engine): Promise<Bday[]> {
99 const today = new Date().toDateString()
100 let cache = (await $.store.get('contacts')) as { day: string; list: Bday[] } | undefined
101 if (cache?.day !== today) {
102 const r = await $.process.run(['osascript', '-l', 'JavaScript', '-e', CONTACTS], { timeoutMs: 90_000 })
103 cache = { day: today, list: r.exitCode ? [] : JSON.parse(r.stdout) }
104 await $.store.set('contacts', cache)
105 }
106 const manual = ((await $.store.get('manual')) as Bday[] | undefined) ?? []
107 return [...new Map([...cache!.list, ...manual].map(b => [b.name.toLowerCase(), b])).values()] // manual wins
108}
109
110// add or correct; a bare first name matches someone already known when it's unambiguous
111async function save($: Engine, name: string, month: number, day: number): Promise<Bday> {
112 const known = (await load($)).filter(b => b.name.toLowerCase() === name.toLowerCase() || first(b.name) === name.toLowerCase())
113 const b = { name: known.length === 1 ? known[0].name : name, month, day }
114 const manual = ((await $.store.get('manual')) as Bday[] | undefined) ?? []
115 await $.store.set('manual', [...manual.filter(x => x.name.toLowerCase() !== b.name.toLowerCase()), b])
116 return b
117}
118
119function until(b: Bday): number {
120 const now = new Date()
121 const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())
122 const next = (y: number) => Math.round((Date.UTC(y, b.month - 1, b.day) - today) / DAY)
123 const d = next(now.getFullYear())
124 return d < -7 ? next(now.getFullYear() + 1) : d
125}
126
127function line(b: Bday): string {
128 const d = until(b)
129 if (d === 0) return `🎂 It's ${b.name}'s birthday today`
130 if (d === 1) return `🎂 ${b.name}'s birthday is tomorrow`
131 if (d > 0) return `🎂 ${b.name}'s birthday is in ${d} days`
132 return `🎂 You missed ${b.name}'s birthday ${-d} day${d === -1 ? '' : 's'} ago`
133}
134
135// Birthday today: 40% of turns for its first 3 showings, then 5%. Otherwise 5% for the week ahead / behind.
136async function pick($: Engine): Promise<Bday | null> {
137 const near = await unwished($, (await load($)).filter(b => until(b) <= 7))
138 const todays = near.filter(b => until(b) === 0)
139 if (!todays.length) return near.length && Math.random() < 0.05 ? any(near) : null
140 const day = new Date().toDateString()
141 const shown = (await $.store.get('shown')) as { day: string; n: Record<string, number> } | undefined
142 const n = (shown?.day === day && shown.n) || {}
143 const fresh = todays.filter(b => (n[b.name] ?? 0) < 3)
144 const b = Math.random() < (fresh.length ? 0.4 : 0.05) ? any(fresh.length ? fresh : todays) : null
145 if (!b) return null
146 n[b.name] = (n[b.name] ?? 0) + 1
147 await $.store.set('shown', { day, n })
148 return b
149}
150
151// Text or dismiss means handled: reminders for them stop for 10 days
152async function unwished($: Engine, list: Bday[]): Promise<Bday[]> {
153 const wished = ((await $.store.get('wished')) as Record<string, number> | undefined) ?? {}
154 return list.filter(b => !(Date.now() - (wished[b.name] ?? 0) < 10 * DAY))
155}
156
157async function markWished($: Engine, b: Bday) {
158 const wished = ((await $.store.get('wished')) as Record<string, number> | undefined) ?? {}
159 await $.store.set('wished', { ...wished, [b.name]: Date.now() })
160 $.ui.invalidate('ui.render') // the card goes away now
161}
162
163// the card: someone whose birthday is today or in the last 7 days, not yet wished
164async function due($: Engine): Promise<Bday | undefined> {
165 return (await unwished($, await load($))).filter(b => until(b) <= 0).sort((a, b) => until(b) - until(a))[0]
166}
167
168// their number or email: from your conversations, else from Contacts
169async function handleFor($: Engine, name: string): Promise<string> {
170 const people = (await chats($)).sort((a, b) => a.size - b.size).flatMap(c => c.members)
171 const fromChats = people.find(m => m.name === name)?.handle
172 if (fromChats) return fromChats
173 const js = `const p = Application('Contacts').people.whose({ name: ${JSON.stringify(name)} })()
174p.length ? p[0].phones.value()[0] || p[0].emails.value()[0] || '' : ''`
175 return (await $.process.run(['osascript', '-l', 'JavaScript', '-e', js])).stdout.trim()
176}
177
178// open Messages with the wish already typed; you press send
179async function textThem($: Engine, b: Bday) {
180 const handle = await handleFor($, b.name)
181 if (!handle) return $.ui.toast(`Couldn't find a number for ${b.name}`)
182 const wish = until(b) === 0 ? 'Happy birthday! 🎂' : 'Happy belated birthday! 🎂'
183 await $.process.run(['open', `sms:${handle}&body=${encodeURIComponent(wish)}`])
184 await markWished($, b)
185}
186
187// /seed: walk the people who matter most to you and fill in the birthdays GoodFriend doesn't know yet
188async function seed($: Engine): Promise<string> {
189 const known = new Set((await load($)).map(b => b.name.toLowerCase()))
190 const me = (await $.process.run(['osascript', '-l', 'JavaScript', '-e', "Application('Contacts').myCard().name()"])).stdout.trim()
191 const todo = await rank($, (await candidates($)).filter(p => !known.has(p.name.toLowerCase()) && p.name !== me), me)
192 if (!todo.length) return "🎂 You already know everyone's birthday. Good friend."
193
194 let saved = 0
195 for (const [i, name] of todo.entries()) {
196 const ask = `When's ${name}'s birthday? Type it under Other, like 10/7 or Oct 7.`
197 const a = await $.ui.ask(ask, { header: `🎂 ${i + 1} of ${todo.length}`, options: ['Skip', ENOUGH] }).catch(() => ENOUGH)
198 if (a === ENOUGH) break
199 const date = parseDate(a)
200 if (date) await save($, name, date[0], date[1]), saved++
201 else if (a !== 'Skip') $.ui.toast(`Couldn't read "${a}", skipped ${name}`)
202 }
203 return saved ? `🎂 Saved ${saved} birthday${saved === 1 ? '' : 's'}. Your friends are lucky to have you.` : 'Nothing saved. /seed any time.'
204}
205
206async function welcome($: Engine) {
207 const go = "Let's do it"
208 const ask = 'GoodFriend reminds you of birthdays right here while you work. Want to add birthdays for the people you text most? It takes a minute.'
209 const a = await $.ui.ask(ask, { header: 'GoodFriend', options: [go, 'Later'] }).catch(() => '')
210 $.ui.toast(a === go ? await seed($) : 'Any time: /seed')
211}
212
213export const register: Register = on => {
214 let current: Bday | null = null
215 let test = false
216
217 on('session.start', async ($, e, next) => {
218 await $.command.register({ name: 'goodfriend', description: 'Birthdays: list | add Name MM/DD | remove Name | test' })
219 await $.command.register({ name: 'seed', description: 'Fill in birthdays for the people you text most' })
220 await $.tool.register({
221 name: 'set_birthday',
222 description:
223 "Save or correct a friend's birthday in GoodFriend, the user's birthday reminder. Use when the user says a birthday is wrong or tells you someone's birthday. Call list_birthdays first to use the exact name GoodFriend has.",
224 inputSchema: {
225 type: 'object',
226 properties: { name: { type: 'string' }, month: { type: 'integer', minimum: 1, maximum: 12 }, day: { type: 'integer', minimum: 1, maximum: 31 } },
227 required: ['name', 'month', 'day'],
228 },
229 })
230 await $.tool.register({ name: 'list_birthdays', description: 'List the birthdays GoodFriend knows, as "Full Name: M/D".' })
231 if (!(await $.store.get('welcomed'))) await $.store.set('welcomed', true), void welcome($)
232 return next(e)
233 })
234
235 on('tool.call', { tool: 'mcp__goodfriend__set_birthday' }, async ($, e) => {
236 const { name, month, day } = e as unknown as { name: string; month: number; day: number } // a tool's arguments ride on the event itself
237 const b = await save($, name, month, day)
238 return { result: `Saved ${b.name}: ${b.month}/${b.day}` }
239 })
240
241 on('tool.call', { tool: 'mcp__goodfriend__list_birthdays' }, async $ => ({
242 result: (await load($)).map(b => `${b.name}: ${b.month}/${b.day}`).join('\n') || 'None yet',
243 }))
244
245 on('turn.start', async ($, e, next) => {
246 current = test ? (await load($)).sort((a, b) => until(a) - until(b))[0] : await pick($)
247 test = false
248 return next(e)
249 })
250
251 on('ui.render', { component: 'Spinner' }, ($, e, next) => next(current ? { ...e, props: { ...e.props, word: line(current) } } : e))
252
253 // the card above the prompt: on the day and up to a week late, until you text or dismiss
254 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
255 const b = e.props.hasSurvey ? undefined : await due($)
256 if (!b) return next(e)
257 const { Box, Button, Text } = $.ui.resolve(e)
258 return (
259 <Box columnGap={2} alignItems="center">
260 <Text>{line(b)}</Text>
261 <Box key="send" backgroundColor={ACCENT} paddingX={1}>
262 <Button key="text" plain label={`Text ${b.name.split(' ')[0]}`} onPress={() => textThem($, b)} />
263 </Box>
264 <Button key="dismiss" role="dismiss" label="Dismiss" onPress={() => markWished($, b)} />
265 </Box>
266 )
267 })
268
269 on('command.run', { command: 'seed' }, async $ => ({ text: await seed($) }))
270
271 on('command.run', { command: 'goodfriend' }, async ($, e) => {
272 const [cmd = '', ...rest] = e.args.trim().split(/\s+/)
273 const arg = rest.join(' ')
274
275 if (cmd === 'test') return (test = true), { text: 'Next turn shows the soonest birthday.' }
276 if (cmd === 'add') {
277 // the date is the last one or two words: "10/7", "Oct 7", "7th October"
278 const words = arg.split(' ')
279 const split = [2, 1].map(k => [words.slice(0, -k).join(' '), parseDate(words.slice(-k).join(' '))] as const).find(([n, d]) => n && d)
280 if (!split) return { text: 'Usage: /goodfriend add Full Name MM/DD' }
281 const [name, [month, day]] = split as [string, [number, number]]
282 const b = await save($, name, month, day)
283 return { text: `🎂 Saved ${b.name}: ${b.month}/${b.day}` }
284 }
285 if (cmd === 'remove') {
286 const manual = ((await $.store.get('manual')) as Bday[] | undefined) ?? []
287 await $.store.set('manual', manual.filter(b => b.name.toLowerCase() !== arg.toLowerCase()))
288 return { text: `Removed ${arg}` }
289 }
290
291 const list = (await load($)).sort((a, b) => until(a) - until(b))
292 const rows = list.map(b => `${until(b) === 0 ? '🎂' : ' '} ${b.name}: ${b.month}/${b.day}`)
293 return { text: rows.join('\n') || 'No birthdays yet. Try /seed' }
294 })
295}
296