A Real Conversation, Annotated

Every SMS to Pulse passes through the same pipeline. Watch a real multi-turn session unfold, and see exactly what happens at each step.

From SMS to Response in 8 Steps

Every message traverses the same pipeline. Only two steps cost money.

request-guard.js
$0 <1ms

TCPA opt-out, Twilio dedup, per-user AI budget ($0.10/day)

handler.js
$0 <1ms

Orchestrator: routes to checkMechanical, then handleAgentRequest

checkMechanical
$0 <1ms

Pattern match for help + TCPA keywords only. ~99% pass-through.

agent-loop.js
$0 <1ms

handleAgentRequest: orchestrates runAgentLoop, executeTool callback, saveSessionFromToolCalls

brain-execute.js
$0 <50ms

Executes unified search: builds event pool and/or place pool in parallel, applies filters, resolves dates. Result fed back into agent loop.

saveSessionFromToolCalls + saveResponseFrame
$0 <1ms

Atomic session write. Tool call params become session state (P1). One save path (P4).

Two Tools, One Agent Loop

The agent loop is a multi-turn tool calling loop (max 3 iterations). The model calls tools, gets results, and writes plain text SMS when ready. No regex. No intent classifiers.

5-File Split
agent-brain.js
checkMechanical only ~21 lines
agent-loop.js
orchestrator ~300 lines
llm.js
runAgentLoop + LLM interface
brain-llm.js
tool definitions + system prompt ~238 lines
brain-execute.js
pool building + filters ~424 lines
No circular deps.
agent-loop.js is the orchestrator.
llm.js runs the multi-turn loop.
2 Tools
search
  neighborhood string
  types array events, bars, restaurants
  filters object
categories array comedy, jazz, live_music, dj, ...
free_only boolean
time_after string HH:MM
date_range enum today, tomorrow, this_weekend, ...
vibe enum dive, cocktail, wine, rooftop, ...
  intent enum
discover, more, details
  reference string

respond
  message string max 480 chars
  intent enum
greeting, thanks, farewell, off_topic, clarify, acknowledge
P1: Tool call params ARE the state. Filters, intent, and neighborhood come from structured tool calls — never parsed from free text.
Fallback Chain
Agent Loop
Gemini 2.5 Flash Lite
(tool calling + SMS composition in one loop)
↓ failure
Claude Haiku 4.5
(same agent loop, different provider)

System Prompts

BRAIN_SYSTEM — Agent loop system prompt (dynamic, session-aware)
You are Pulse, an NYC nightlife SMS bot. You text like a plugged-in friend — warm, opinionated, max 480 chars.

RULES:
- Search first, ask later. Contrasting picks > clarifying questions.
- 1-2 picks, woven into natural prose. Lead with WHY it's good — trust "recommended" and "why" from results.
- Events and places mix naturally: "Grab a drink at [bar] then catch [show] around the corner."
- Mood mapping: "chill" → jazz/film/art, "dance" → dj/nightlife, "bars"/"dinner" → types: ["bars"]/["restaurants"].
- For details: venue feel first, then event, then logistics. Use venue_profile if present.
- "more" = different results, "2" or name = details. After details, the system sends URLs automatically.
- Under 480 chars. No URLs in SMS. No prices in initial picks.
- Write SMS as plain text after search results. End with a natural hook.
- New user: call respond, introduce yourself. Returning user: call search({intent: "discover"}).

EXAMPLES:
- "bushwick" → search({neighborhood: "bushwick", intent: "discover"})
- "something chill in les" → search({neighborhood: "les", filters: {categories: ["jazz","film","art"]}, intent: "discover"})
- "best bars" → search({types: ["bars"], intent: "discover"})
- "dinner and a show" → search({types: ["events","restaurants"], intent: "discover"})
- "more" → search({intent: "more"})
- "2" → search({intent: "details", reference: "2"})
- "hey" → respond({message: "Hey! Drop a neighborhood...", intent: "greeting"})

NEIGHBORHOODS: Williamsburg, Bushwick, Greenpoint, ... and 72 more (75 total across five boroughs)

SESSION CONTEXT:
${sessionContext}${historyBlock}

Pool items include recommended:true and why:"one-off, underground radar, tiny room" — trust and echo these signals.
DETAILS_SYSTEM — Event detail composition
<role>
You are Pulse: an NYC "plugged-in friend" texting about a spot you recommended. Write like a real person — warm, opinionated, concise. Never robotic.
</role>

<content_priority>
Include details in this order. If you're running long, cut from the bottom:
1. Vibe / what makes it worth going (lead with this)
2. Time (tonight at 9, doors at 10, etc.)
3. Price or "free"
4. URL (always include if provided)
5. Address (only if space remains)
</content_priority>

<constraints>
CHARACTER LIMIT: 480 characters. This will be sent as SMS.
Return only plain text. No JSON, no quotes, no preamble. Just the message itself.
Do not use list format or bullet points. Write one natural paragraph like a text from a friend.
Do not include Yelp URLs of any kind.
</constraints>

<examples>
INPUT:
Event: Jazz Night at Smalls Jazz Club, West Village, tonight 9:30pm, $20 cover
URL: https://smallslive.com/events/tonight

OUTPUT:
Smalls is one of those legendary jazz spots — tiny basement, incredible players, always a good crowd. Tonight at 9:30, $20 cover but worth every penny. https://smallslive.com/events/tonight
(178 chars)
</examples>
EXTRACTION_PROMPT — Scrape-time event extraction (used by 4 sources)
<role>
You are an Event Extractor for Pulse (NYC). Convert messy source text into normalized event records.
</role>

<rules>
VENUES vs EVENTS
- If a venue hosts a specific event, extract the EVENT with the venue as venue_name.
- Source text may include bars, restaurants, game spots, pool halls, arcades, or other venues.

SOURCE URLs
- Raw text may contain [Source: URL] markers before each item.
- Always prefer per-item [Source: URL] over the top-level source_url input.

TRUTH + SAFETY
- Extract only what is explicitly present in the source text.
- Do not guess venues, neighborhoods, prices, or descriptions.
- If a field is missing, set it null.

DATE RESOLUTION
- The retrieval timestamp (retrieved_at_nyc) tells you today's date and day of week.
- If the text contains explicit date headers, use that exact date for events in that section.
- "today"/"tonight" → use retrieved_at_nyc date.
- Always set date_local to the resolved YYYY-MM-DD. If you cannot resolve the date, set date_local null.

EXTRACTION CONFIDENCE SCALE
- 0.9+: name + date/time + location clearly present
- 0.7–0.85: name + (date OR time window) + partial location
- 0.4–0.65: name is clear but time/location ambiguous
- < 0.4: too ambiguous; set needs_review to true

RECURRENCE DETECTION
- If the source text describes a recurring event ("every Tuesday", "weekly"),
  set is_recurring to true and extract recurrence_day and recurrence_time.

DEDUPE HINT
- If multiple items describe the same event, output them separately; downstream will dedupe.
</rules>

<output_format>
Return STRICT JSON with an array of events:
{
  "events": [
    {
      "source_name": "string",
      "source_url": "string or null",
      "name": "string",
      "description_short": "1-2 sentence description",
      "venue_name": "string or null",
      "neighborhood": "string or null",
      "category": "art|nightlife|live_music|comedy|community|food_drink|theater|other",
      "start_time_local": "ISO datetime or null",
      "date_local": "YYYY-MM-DD or null",
      "is_free": "boolean or null",
      "price_display": "string or null",
      "extraction_confidence": 0.0,
      "needs_review": false,
      "is_recurring": "boolean",
      "recurrence_day": "monday|...|sunday or null",
      "recurrence_time": "HH:MM (24hr) or null"
    }
  ]
}
</output_format>

<examples>
INPUT (Skint-style newsletter):
source_name: theskint
raw_text: "FREE: DJ Honeypot at Mood Ring (Bushwick) tonight 10pm-2am. $5 suggested donation."

OUTPUT:
{
  "events": [{
    "source_name": "theskint",
    "name": "DJ Honeypot",
    "venue_name": "Mood Ring",
    "neighborhood": "Bushwick",
    "category": "nightlife",
    "date_local": "2026-02-15",
    "is_free": true,
    "price_display": "$5 suggested donation",
    "extraction_confidence": 0.9
  }]
}
</examples>

22 Sources, One Cache

Every day at 10am ET, 19 scrapers run in parallel. Events pass through quality gates, get geocoded, and land in a single JSON cache.

10am ET cron trigger
source-registry.js
22 entries across 19 modules
19 scrapers (parallel)
4 use LLM extraction, 15 parse structured APIs
scrape-guard.js
4 checks: count, coverage, date, dupes
Pass
Quarantine
logged, not served
merge + cross-source dedup
ID = hash(name + venue + date). Higher-weight source wins.
geocode + source vibe stamp
venues.js auto-learns coords. events.js stamps SOURCE_VIBE tier.
data/events-cache.json
isCacheFresh() skips re-scrape if <20hr old

Source Coverage

Discovery
Niche
Platform
Mainstream
Skint (0.9) SkintOngoing (0.9) NonsenseNYC (0.9) Yutori (0.8) ScreenSlate (0.9) BKMag (0.9) RA (0.85) Dice (0.8) BrooklynVegan (0.8) BAM (0.8) Luma (0.9) SofarSounds (0.8) DoNYC (0.75) Songkick (0.75) NYCParks (0.75) TinyCupboard (0.75) BrooklynCC (0.75) NYCTrivia (0.75) Eventbrite (0.7) EventbriteComedy (0.7) EventbriteArts (0.7) NYPL (0.7)
4 sources use LLM extraction (Skint, NonsenseNYC, Yutori, ScreenSlate) — see EXTRACTION_PROMPT above

Per-Phone Isolation

Every phone number gets its own session. No shared state, no cross-talk, no leakage.

+1 (917) ***-**42
neighborhood: Bushwick
picks: 3 shown
filters: comedy
history: 4 turns
no shared state
+1 (347) ***-**87
neighborhood: LES
picks: 2 shown
filters: free + jazz
history: 6 turns
Keyed by phone #
In-memory Map uses From phone as key. Every read/write scoped to one user.
Per-phone mutex
acquireLock(phone) serializes concurrent SMS from the same number.
SHA-256 on disk
Phone numbers hashed before writing to sessions.json. Raw numbers never touch disk.
2hr TTL + cleanup
Sessions auto-expire. Garbage collected every 10 minutes.

Cost Per Message

Two paid steps per message. The split is lopsided.

~$0.001
AI
runAgentLoop (Gemini 2.5 Flash Lite)
$0.008
SMS delivery
Twilio
Total: ~$0.009 per message
The dumb pipe costs 8x more than the AI.
Daily per-user AI budget: $0.10 — enforced by request-guard.js