Inspiration
Living in New York is hectic. Every ordinary thing, like coffee before class or a catch-up Sunday brunch between two friends that live on opposite sides of the city, requires a round of scheduling, a round of “what should we eat”, and finally a round of mental gymnastics to calculate whether you have the budget for it this week.
Trying to coordinate that between many people in a group chat is where plans go to die. Someone says "dinner Friday?", six people say "I'm down", and then nothing happens for four days because the questions that would actually settle it are the ones nobody wants to ask out loud. What can you afford. How far will you really travel. What can't you eat. Saying "$15 is my limit" in front of nine people costs something, so nobody says it, and the plan either dies or lands somewhere half the group can't afford and nobody enjoys.
That is a small thing that happens constantly, and it lands hardest on exactly the person least able to speak up. So we built Unsaid: every person gets their own agent, in their own iMessage thread, and the agents do the arguing. Some things are better left unsaid.
What it does
You text Unsaid like a friend. It asks where you're coming from, when you're free, how far you'll go, what you can't eat, and what you want to spend. Instead of demanding a number, it pulls what you usually spend from Capital One's Nessie API and proposes one, so all you have to say is "yeah, that works."
Everyone else joins with a four-character code typed into their own 1:1 thread. When the host texts go, the agents negotiate. What comes back is a native iMessage poll with the top three places that clear everyone's limits, and once the group votes, everyone gets the same plan — with a map link, from where they happen to be standing.
The part we care about most is what the orchestrator never learns. Each agent sees only its own person. What gets pooled is three facts: the lowest budget, everyone's dietary needs combined, and the one time window that overlaps. Nobody finds out whose budget set the ceiling.
How we built it
- Split up into two workflows. One took messaging and flow (Spectrum, the router, join codes, fan-out, Nessie), the other took agents and data (Grok extraction and scoring, the venue catalogue, the negotiation engine, the backroom screen). Shared types live in src/contracts.ts
- Our Photon credits were Pro, not Business, so Unsaid can't sit inside a group chat. Everything became 1:1 DMs plus a four-character join code.
- The app is one loop: Spectrum({ providers: [imessage.config()] }), then every inbound message goes through a router to that person's agent.
- Grok (the OpenAI SDK pointed at api.x.ai/v1) pulls the person's exact words into typed fields. Our own resolvers turn those words into values: times, coordinates, dollars, dietary tags. If Grok is unreachable the resolvers run on their own.
- Venues are a build step, not a runtime call. npm run venues pulls 1,089 NYC places from Google Places with hours, price and ratings and commits them as JSON, so the app never needs a Places key.
- Atlas holds plans (each person's answers under their own key), users, message transcripts, negotiation rounds, and paused negotiations.
Challenges we ran into
- Bare numbers are ambiguous. "30" is a budget, a travel time and half a clock. "60th and Lex" set a $60 budget; "around 7:30" set a $30 one. We track which slot the agent just asked about and let that resolver win on short replies.
- Questions were parsed as answers. "what are the options", "who's voted", "why these three" used to land in whatever slot was open next.
- The replies were rigid. It only understood exact answers and repeated the same canned line if you wrote twice. We added the ordinary ways people reply: yes / yeah / sure / works, a 👍 tapback, iMessage's "Liked ..." text, and votes as "2", "#2", the restaurant's name, or a tap on the poll. Nudges rotate, and every reply repeats back what it understood.
- Retries had to ask for something easier, not the same question with more examples. A neighbourhood always resolves, an address might not. After two tries the agent assumes a default and says what it assumed.
- Keeping plan data and person data apart. Budget and availability belong to one plan and die with it. Home, dietary needs, standing blackouts and favourite spots belong to you and carry into the next plan. That meant narrow field-level writes so a join couldn't overwrite a profile, and leaving a plan had to drop your answers along with your membership.
Accomplishments that we're proud of
- The negotiation loop settles. Each agent scores options for its own person and concedes in rounds by a share of what that person said, not a flat amount. We pick the option with the best worst-case score across everyone, tie-broken by total score and then by the shortest longest commute. A real tie goes back to the group instead of being decided for them.
- Real restaurant data. 1,089 NYC places from Google Places with genuine opening hours, so 8am coffee, Sunday brunch and 11pm drinks are different sets of options.
- It lives in iMessage properly. Native polls you tap to vote on (and untap to take your vote back), tapback confirmation, and map links that preview as cards.
What we learned
- How an agent loop is actually shaped: the model reads, the code decides. Extraction returns the person's words, resolvers turn them into values, the negotiation is deterministic, and every model call has a fallback.
- Personality came from coverage, not prompting. Handling the common ways people text did more than any instruction to sound friendly.
- Splitting by lane worked because of the shared contracts file and a harness we could both run. Neither of us read most of the other's code, but we could both check the seam.
What's next for unsaid
- @-mention Unsaid in the group chat instead of sharing a join code. That needs the Business tier.
- Google Calendar: read availability instead of asking for it, and write the settled plan back as an event.
- Forward a place to save it — the restaurant a friend sent you, straight into the thread, remembered for every plan after.
Built With
- claude
- cursor
- grok
- mongodb
- nessie
- photon
- typescript
Log in or sign up for Devpost to join the conversation.