Inspiration

Every friend group has that thread: 87 messages, three polls, and Saturday still isn't decided. One person ends up as the coordinator. They chase replies, remember who's vegetarian and do the carpool math in their head. And the private details never get said out loud: nobody wants to post "my budget is $30" or "I have a severe peanut allergy" to the whole group.

We wanted the friend who actually organizes the plan, living where people already talk. No app, no signup, no form, just a text thread. The Photon Agents in iMessage track was the perfect excuse to build it.

What it does

Juno is an event-planning agent that lives in iMessage. Humans decide what matters; Juno does the chasing and the math.

  1. Kick off in one line. The organizer texts "plan a hike + dinner this saturday near campus, max $40 each". Juno asks only for what's missing, then confirms a summary (reply yes, or tap 👍 on it). The organizer types who's coming or shares their contact cards. If the organizer is coming too ("I'm in too and can drive 2"), Juno collects their answers in the same thread. The organizer also gets a private link to a live dashboard.

  2. Private questions, one at a time. Juno texts each invitee in their own thread. The first message is plain text, names the organizer and ends with a question; "not now" gets "No worries — text me whenever you have a minute." Juno then asks:

    • when they're free and when they need to be home;
    • their allergies, or foods they don't eat;
    • whether they drive (and how many seats), or need a ride and from which landmark;
    • whether the organizer's budget cap works for them.

It never re-asks what someone has already said, and it pulls several answers out of one message. A landmark like "the library" is looked up inside the event area; if several places match, Juno lists them by number. At the end it reads back a summary built from the stored data. Nothing counts until the person confirms it. Juno then asks for an optional email for the calendar invite. Anyone can change an answer at any time.

  1. It remembers, with permission. The first time someone confirms, Juno tells them it will remember their food and ride details for next time. On the next invite it opens with "I remember your details from last time", then asks a single question: "From last time I have: allergic to peanuts · needs a ride from Main Library. Still right?" A few rules apply:

    • Times and budgets are never remembered.
    • A remembered pickup spot in another city is dropped.
    • Memory only appears in that person's own thread.
    • Texting "forget me" deletes it.
  2. A plan that's computed, not guessed. Once everyone has confirmed, or the organizer says plan it, a deterministic solver builds Plan A and Plan B. Each plan has pickup times, who drives whom, the activity, dinner, drop-offs and an upper bound on each person's cost. Plan B states its trade-off: "Plan B: Olive Tree (up to $38 per person) needs no call, but Leo (over budget) would have to sit this one out." Anything the data can't confirm becomes a call-first item: "⚠️ One thing I can't confirm: whether Maple Kitchen can handle a severe peanut allergy. Could you give them a call? 555-0142". The organizer can adjust the plan in plain English ("can we start at 4?"), and Juno re-solves.

  3. Humans verify, then approve. A plan with an unconfirmed allergy can't be approved. When the organizer texts "just called, they said they can do it", Juno records who confirmed, how and when: "Noted — confirmed by you by phone at 7:12 PM. Plan A is ready." If the restaurant says no, Juno drops it and re-plans. Nothing reaches attendees until the organizer texts approve A, and approval only works for the current version of the plan.

  4. Everyone gets their own plan. After approval, each person gets their own itinerary in iMessage, with confetti.

    • Riders see who picks them up, where and when; drivers see their pickup route.
    • Everyone sees the activity, dinner, their own cost ceiling and their drop-off time.
    • If the organizer confirmed a restaurant can handle someone's allergy, that person's message says so: "Alex confirmed with them that they can handle your peanut allergy."
    • A link preview opens their personal itinerary page, with a map and an Add to calendar button.
    • Anyone who left an email also gets a personal email with the calendar invite.
    • Anyone the plan leaves out gets a kind private note that gives only their own reason.
    • If someone later asks "where are we going for dinner?", Juno sends them their plan again.
  5. Changes after publishing. Say Priya texts "ugh my car's in the shop, can't drive tomorrow." Juno first checks the change with her, then re-solves. The new plan needs Leo, who said he'd drive only if needed, so Juno asks him privately first: "could you take Sam and Priya? It's about 10 extra minutes." The organizer then gets a proposal listing what changed and who isn't affected. After approve, only the affected people hear about it. Their itinerary pages show Updated and what changed, and their calendar events are updated in place.

  6. A live dashboard. The organizer's dashboard, made for the big screen, shows:

    • who has confirmed and who Juno is still waiting on;
    • pickup spots on a map;
    • the Plan A/B timelines and car routes;
    • call-first items;
    • who is left out and why, by category;
    • pending changes;
    • whether each person's notification went through.

It's read-only. Every decision happens in iMessage.

How we built it: Huge Thanks to Photon Spectrum!!

  • One Bun + TypeScript process runs the agent, a Hono web server and SQLite. The dashboard and itinerary pages are React + Vite and poll a JSON API.
  • Photon Spectrum is our only messaging layer. We use its iMessage and terminal providers, plus a platform we wrote with definePlatform: a web simulator that shows five iPhones side by side, with iMessage-style bubbles, typing indicators, tappable tapbacks, link cards and confetti. It registers next to iMessage, so real phones and simulated friends can share one event and run exactly the same code.
  • It feels native to iMessage.
    • A typing indicator shows while Juno works.
    • A burst of messages is debounced into one turn.
    • A ❤️ tapback replaces a "got it" message.
    • The final plan arrives with confetti and a rich link preview.
    • People on SMS or RCS get plain text with bare URLs.
  • The LLM proposes; the code decides.
    • Each turn makes one Claude call (Claude Opus 5 at low effort, with a cached system prompt). A Zod schema makes it return {intent, patch, askingAbout, reply}.
    • Claude extracts fields and phrases questions. The code validates the fields and picks the next question, and it uses Claude's wording only when Claude is asking about exactly that field.
    • Summaries, plans, notifications, emails and calendar invites come from templates, so they always match the data.
    • Approvals and verifications are parsed by rules only.
    • If Claude fails or takes longer than 8 seconds, a rule-based parser takes over. The whole flow also runs with LLM=off.
  • Privacy by projection. Every output goes through one projection module: the web API, iMessage text, email, calendar invites and the LLM's context.
    • The organizer sees "someone has a severe peanut allergy", but never who has it and never a budget number. If a plan leaves someone out, the organizer sees only a reason category such as "over budget".
    • Each friend sees only their own plan, plus their carmates' names and pickup spots.
    • Claude sees only what the person it's talking to is allowed to see.
    • Group chats are ignored entirely.
  • Reliability.
    • Each turn is an optimistic transaction. If another turn changed the same rows, it re-runs on fresh data. If a new message arrives mid-turn, the draft reply is dropped and the messages are merged into one turn.
    • Messages are deduplicated by ID, and anything received but not yet handled is finished after a restart.
    • All outgoing messages pass through one outbox, which handles quiet hours, keeps per-person delivery records, and tells the organizer when a message fails.
    • Every plan is tied to an input version, so a stale plan can't be approved.
    • Phone numbers are normalized ((314) 555-0101 → +13145550101).
    • A startup check refuses to expose the simulator on a public URL without a key.
  • Maps.
    • Google Places API (New) finds venues and pickup landmarks, and the Routes API's computeRouteMatrix gives drive times.
    • Static Maps images are proxied through our server, so the key never reaches the browser. Without a key, the pages draw an SVG route map instead.
    • Results are cached locally, so a demo can run from the cache without calling Maps live.
    • Maps data never supplies allergy facts; they all start as UNKNOWN. A venue without a price never enters a plan.
  • Calendar and email. Each person's calendar invite has a stable UID (a hash, never their phone number) and $\text{SEQUENCE} = n_{\text{publications}} - 1$, so an update replaces the original event. The same invite is attached to the personal email (METHOD:REQUEST) and can be downloaded from the itinerary page. In this build, emails are written to a local outbox as .eml files; adding a sending provider means writing one class.
  • Tests. 153 bun test tests cover the solver invariants, privacy projections, approval checks, the message pipeline and the full post-publish change scenario.

Challenges we ran into

  • iMessage's rules. On a shared number pool, a message to an unregistered number fails only at send time (Target not allowed). Juno now tells the organizer exactly who wasn't reached and how to retry (invite Sam). Apple's spam filters shaped the copy too: the first message is plain text, names the organizer and ends with a question, and links only come after a reply. Juno never texts anyone first between 10 PM and 8 AM; those messages wait until morning.
  • Messages lost across restarts. Spectrum's stream cursor lives in memory, so messages that arrive while the server is down are never redelivered. We store every incoming message until its turn commits, mark it handled in the same transaction, and resume unfinished turns on startup.
  • Private details leaking into groups. An early version treated a group chat as a person's conversation, so a DM summary with someone's allergies could have landed in the group. Now group messages are ignored entirely.
  • Concurrency in a chat bot. Two people answering at once could overwrite each other's writes, which could even let a stale plan pass the approval check. That's why every turn became an optimistic transaction.
  • A fragile typing indicator. The SDK helper starts the typing indicator outside its try block, so one typing error aborted the whole turn. We now switch typing on and off ourselves and only log failures.
  • Messy language. "has sam answered yet?" was read as inviting "Has", "Answered" and "Yet". "library" matched three buildings, one of them in another city. We added invite cues, stop words, numbered choices and a distance filter.
  • A cache bug in plain sight. Our map-prefetch script built the search area as {label, lat, lng, radiusKm}, while the app built {label, radiusKm, lat, lng}. The cache key hashes the JSON, so in demo mode every venue lookup missed. Moving both behind one function fixed it.

Accomplishments that we're proud of

  • The whole loop works end to end: kickoff, private collection, Plan A/B, verification, approval, personal delivery, and a post-publish change that reaches only the people affected.
  • Three promises are enforced in code, not in prompts:
    • nothing goes out without the organizer's approval;
    • nobody's allergy or budget is bent to make a plan work;
    • private details stay in private threads.
  • It degrades gracefully. Without Claude, rules take over. Without a maps key, it uses the cache and an SVG map. Without iMessage, the simulator runs the same code.

What we learned

  • LLMs are great at messy human replies ("after 3, need to be home by 10 though"), but facts should come from code. Limiting Claude to understanding and phrasing made Juno both friendly and trustworthy.
  • A chat agent is a distributed system in disguise. At-least-once delivery, message bursts, restarts and concurrent writers all show up as soon as real people start texting.
  • At this scale, a small exhaustive search with a lexicographic objective beats clever heuristics, and every trade-off stays explainable ("Leo would sit out: over budget").
  • Humans should stay in the loop exactly where they belong: approving the plan, calling the restaurant, agreeing to drive.

Built With

Share this project:

Updates

Submission history