An always-on iMessage bot with an interactive CLI. It answers incoming messages from known users with an LLM (Pioneer AI), and lets you trigger check-in messages on demand from the command line.
- macOS (this reads the local Messages database — it will not work elsewhere)
- Bun ≥ 1.0 (iMessage bot)
- Node ≥ 20 (calendar auth / CLI helpers)
- Messages.app signed in to iMessage
- A Pioneer AI API key
- A Google Cloud project with the Calendar API enabled (for scheduling tools)
1. Install dependencies
bun install
# or: npm install2. Add your API key
cp .env.example .envThen edit .env and paste in your Pioneer key (and optional calendar overrides).
3. Connect Google Calendar (one-time, per user)
Each user connects their own Google account, so run this once per person and sign in as that person each time:
npm run auth -- --user arne
npm run auth -- --user richieTokens are stored separately in .tokens/<user>.json (gitignored). A user who
skips this still chats normally — they just have no calendar.
Register this redirect URI on your OAuth web client first:
http://localhost:8765/oauth2callback
After that the bot refreshes each token silently.
Sanity check: npm run smoke:calendar (add a user key to check a specific one).
4. Start Actian (optional, for semantic search)
docker compose up -dThis runs Actian VectorAI DB locally — REST on 6573, web UI on http://localhost:6575. Without it the bot still answers schedule questions using calendar keyword search; with it, meaning-based questions work too (see below). Then index a user's events from the REPL:
lockedin(arne)> /index
Indexed 214 events.
5. Grant macOS permissions
The bot needs two separate permissions, both for the terminal app you run it from (Terminal, iTerm, VS Code, …):
| Permission | Where | Why |
|---|---|---|
| Full Disk Access | System Settings → Privacy & Security → Full Disk Access | to read the Messages database |
| Automation → Messages | System Settings → Privacy & Security → Automation | to send replies |
After granting Full Disk Access, fully quit and reopen your terminal — the permission only applies to newly launched processes.
Automation usually prompts on first run ("Terminal wants to control Messages") — click OK. To trigger it manually:
osascript -e 'tell application "Messages" to get name'6. Run it
bun startOn startup each user reports separately:
📅 arne: calendar ready (primary, Europe/Berlin)
📅 richie: no calendar — `npm run auth -- --user richie`
Text a known user things like “what's on my calendar tomorrow?”, “when am I free
next week?”, or “when are my flights again?” — lockedin.log / /log should show
🔧 list_events, 🔧 find_free_time or 🔧 search_events before the reply.
Separate from the operator CLI below — these are commands the user texts:
| Command | What it does |
|---|---|
/feedback <text> |
tell the agent how to treat you — tone, timing, what to drop |
/reflect |
check in on how things are going; the next message is captured |
/profile |
see what the agent thinks about you and how you like to be talked to |
/help |
list these |
/feedback is deliberately the only way to set preferences: "be blunter" and
"don't text before 9am" are the same act, so they share one command. The editor
model decides which section of the profile each statement belongs in.
Anything not starting with / is a normal conversation.
- Register them in
src/config.tsunderUSERS— a short key, their name, and their number in E.164 form (+4917645608285, no spaces). - Add them as a Google test user. In Google Cloud Console → APIs & Services → OAuth consent screen → Audience, add their Google address under Test users. While the app is unpublished, Google refuses consent for anyone not on that list — this is the step people miss.
- Run consent as them:
npm run auth -- --user <key>. The browser opens on this machine, so they need to sign in themselves — in person or over a screen share. Their token lands in.tokens/<key>.json. - Index their calendar: start the bot,
/setuser <key>, then/sync. - Set their voice if they want the plainer one:
/genz off. - Have them text the Mac from that number. Tell them to send
/helpto see what they can do.
Nothing else is per-user — the Pioneer key, Actian, and macOS permissions are all shared by the machine.
Each user has a genZ toggle stored in .lockedin-state.json. On (the default)
is the slang-and-emoji register; off is the same person written plainly. Same
briefing, both modes:
on — heard u're trying to ship that onboarding fix today and actually get some sleep for once ngl … u surviving this shift or what 💀
off — That onboarding fix better finally cooperate today so you can actually hit your midnight sleep goal. … Did you end up finding the root cause of that bug yet?
Toggle with /genz on|off. It applies everywhere — replies, briefings, reminders.
The bot texts ~30 minutes before events worth remembering, and stays quiet about the rest. Deciding which is the interesting part: structural rules discard the obvious noise (all-day entries, blocks the agent booked itself), and only genuinely ambiguous events cost an LLM call, whose verdict is cached on the event.
high Doctor appointment — Dr. Weber → reminded
high LH1234 to LIS → reminded
high Dinner with Anna → reminded
normal Daily standup → quiet
low Deep work: Q3 planning doc → quiet
Only high is ever texted about. A failed or unparsed verdict falls back to
normal, so an error means silence rather than a spurious 3am text.
One cache — .lockedin-events.json — is the single structure all three read:
| field | used by |
|---|---|
fingerprint |
sync: detects a changed event so it is re-embedded and re-judged |
importance |
reminders: only high is texted about |
indexedAt |
sync: what has reached Actian |
remindedAt |
reminders: the dedupe key, so nobody is texted twice |
Two cadences run against it. Sync (every 15 min) is the only thing that talks to Google: it fetches, diffs by fingerprint, embeds changed events into Actian, assesses new ones, and prunes deletions. Unchanged events cost nothing on a re-sync, which matters because embedding and assessment are both paid calls. The reminder tick (every 60 s) reads only the cache — no API calls at all — so it can run often and cheaply.
remindedAt is written before the send is attempted, on the grounds that a
double text is worse than a missed one.
Each user has a markdown profile at profiles/<key>.md with five sections —
communication preferences, what seems to work, context, timing, and reflections.
It is injected into every prompt, and it is plain markdown so you can also just
open and edit it by hand.
When feedback or a reflection arrives, a second LLM pass folds it into the
document. Because markdown carries no schema, every write is validated (required
headings must survive, 6k character cap) and snapshotted first — a mangled rewrite
is rejected, and /profile undo reverts the last change.
Precedence matters and is stated in the prompt: what the user asked for directly outranks what the agent inferred.
Two things run at once: a background poller that auto-replies to incoming DMs, and
a prompt where you type commands. Background activity is not printed — it goes
to lockedin.log and the /log feed — so the prompt stays readable.
lockedin> /setuser arne
Selected Arne (+4917645608285)
lockedin(arne)> /daily
Generating daily briefing for Arne…
Sent to Arne: Hope you managed to ship that onboarding fix today…
| Command | What it does |
|---|---|
/setuser <arne|richie> |
select who commands act on |
/who |
show the selected user |
/daily [--dry] |
generate and send a daily check-in |
/weekly [--dry] |
generate and send a weekly rundown |
/say <text> |
send a literal message (no LLM) |
/reset |
forget the conversation so far — clean slate |
/profile [undo] |
show their learned profile, or revert the last edit |
/genz [on|off] |
slang+emoji voice, or the plainer serious one |
/sync [full] |
pull their calendar into the cache + vector DB |
/reminders |
upcoming important events and reminder status |
/remind [--dry] |
send any reminders due right now |
/askreflect |
ask them for a reflection (weekly feedback round) |
/log |
watch the live feed — Enter to exit |
/help |
list commands |
/quit |
stop lockedin |
--dry prints the generated message instead of sending it — use it while tuning
prompts.
Prompt text lives in prompts/daily.md and prompts/weekly.md, so you can
tune the tone without touching code. Templates use {{placeholders}}:
{{user}} · {{date}} · {{calendar}} · {{mood}} · {{goals}} · {{recent_messages}}
{{calendar}} is live Google Calendar data when authorized. {{goals}} and
{{mood}} are read from the user's profile — the Goals section, and their most
recent reflection. When a section is empty the placeholder says so explicitly and
the templates instruct the model to ask rather than invent.
Separate from the iMessage REPL — useful for seeding a demo calendar or planning offline:
| Command | What it does |
|---|---|
npm run auth -- --user <key> |
One-time Google consent for that user |
npm run preview |
Example 14-day schedule + free time (no Google) |
npm run seed |
Write the example schedule into your calendar |
npm run agenda / free / plan |
Inspect / plan focus blocks |
npm run cal -- clear |
Remove seeded / agent-written events only |
lockedin.ts entry point — calendar init, poller, CLI
src/config.ts users, voice, env validation
src/logger.ts file log + ring buffer + live subscribers
src/llm.ts Pioneer client (+ tool-calling loop)
src/messages.ts iMessage send / history / poll loop
src/autoreply.ts auto-reply (uses calendar tools when available)
src/context.ts briefing context (live calendar + placeholders)
src/briefing.ts daily & weekly generation
src/cli.ts iMessage REPL (/daily, /reset, /profile, …)
src/usercmd.ts commands the USER texts (/feedback, /reflect, /profile)
src/profile/ markdown profile store + the editor LLM
src/retrieval/ semantic (Actian) + keyword (Google) event search
src/events/ event cache, sync, importance, reminders
src/calendar/ Google Calendar layer + agent tools
prompts/ editable briefing templates
Users live in src/config.ts as the single source of truth — the auto-reply
whitelist and the /setuser options are both derived from it. Add a person there
and everything else follows.
- Direct messages only. Group chats are ignored, even if a known user posts in one.
- History comes from
chat.db, not a separate database, so it stays in sync with what you see in Messages.app. - Messages sent while the bot is offline are skipped — it only responds to messages that arrive while it's running.
- Calendars are per user. Each person authorizes their own Google account and gets their own token; nobody sees anyone else's schedule.
- Far-future questions work without a date range. The
search_eventstool searches the whole calendar, so "when are my flights" resolves for a trip months out. Two providers answer it together: Actian matches on meaning ("when do I fly to Lisbon" finds an event titled "LH1234 to LIS"), Google matches on exact keywords. Results are merged and deduped; if Actian is not running, keyword search alone still answers. - Semantic results have a score floor (
MIN_SCOREinsrc/retrieval/actianClient.ts). Nearest-neighbour search always returns something, so without a floor an unrelated question comes back with the least unrelated event and the model treats it as an answer. - Re-indexing is safe. Point ids are derived from the Google event id, so
/indexoverwrites rather than duplicating, and edited events update in place. /resetdoesn't delete anything. Your Messages history is untouched; the command records a cutoff (in.lockedin-state.json) and the bot ignores anything before it. The cutoff is per user and survives restarts./whoshows it.
Nothing happens when you text it:
- Run
/log— if there's no📩line, the message wasn't picked up. - Confirm the sender is in
USERS(src/config.ts) and it's a direct message. - Re-check the two permissions above, and remember to restart your terminal after granting Full Disk Access.