The AI that texts like a friend who actually pays attention.
Palmer is a personal AI delivered entirely over SMS. No app to download, no interface to learn — just text it. It knows who you are, remembers what you care about, and reaches out when something worth knowing happens.
Every exchange teaches Palmer something new. Your city, your job, your teams, your interests — it builds a picture of you and uses it the way a real friend would: casually, in context, without citation. The longer you text, the sharper it gets.
Each morning Palmer texts you a short, personalized briefing on what you actually care about — weather, local traffic, markets, sports, news. Not a newsletter. One text, just the things that matter, from today.
Tell Palmer to watch for something — a geopolitical event, a company move, an athlete's health update, anything — and it runs that in the background. When it hits, you get a text. No feed to check.
Natural-language reminders that arrive when you need them. "Remind me Friday morning to prep for the meeting." Done.
Every week or two, one text about one thing: how your team did, a story on a subject you follow, or something you mentioned — an interview, a move, a rough week. Not scripted, not daily. Anything you set a watch on still arrives when it moves; nothing else arrives on Palmer's own initiative.
Follow a team and it shows up in your morning text and on your page. Want texts during the game? Say so, and pick a level: the big moments only (lead changes, a late score, the final) or every score. Palmer offers it once when you mention a team, and never turns it on for you.
Send Palmer a picture and it'll actually respond to what's in it — a menu, a whiteboard, a receipt, a dog. It's using vision, not guessing from a filename.
Crypto prices, stock quotes, weather forecasts, sports scores, current events, city traffic, drive times with live traffic — all through the same text thread, no switching apps.
Tell Palmer to pause the morning texts, drop a topic, forget a fact about you, or change when it sends — all by texting. No settings screen.
Palmer is dry, quick, and observant. It's not an assistant and it's not a brand — it's a voice. It reads subtext, matches your energy, calls out patterns, and knows when to drop a GIF. Built on Claude, it converses like a person, not a product.
| Layer | Technology |
|---|---|
| Conversation + reasoning | Claude Sonnet 4.6 |
| Fast inference (extraction, scoring) | Claude Haiku 4.5 |
| SMS/MMS | Twilio |
| Web server | FastAPI on Heroku |
| News + web search | Tavily |
| Weather | NWS (US), Open-Meteo (elsewhere) |
| Traffic + routing | TomTom (Traffic Flow, Traffic Incidents, Routing, Search geocoding) |
| Product prices + sale watches | SerpAPI Google Shopping |
| Crypto prices | CoinGecko |
| Stock prices | yfinance |
| GIFs | Giphy |
| Background jobs | APScheduler (reminders 1m · mornings 5m · watches 30m · missing-data asks 60m · check-ins every 2h tick, paced in days · live scores 2m, opt-in per team · price watches twice daily · flight watches daily) |
| Database | Heroku Postgres |
1. Clone and install
git clone https://github.com/pythonjeff/Palmer.git
cd Palmer
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt2. Environment
cp .env.example .env
# Fill in the variables below| Variable | Where to get it |
|---|---|
ANTHROPIC_API_KEY |
console.anthropic.com |
TWILIO_ACCOUNT_SID |
twilio.com dashboard |
TWILIO_AUTH_TOKEN |
twilio.com dashboard |
TWILIO_PHONE_NUMBER |
Your Twilio number (+15551234567) |
TAVILY_API_KEY |
app.tavily.com |
GIPHY_API_KEY |
developers.giphy.com (free) |
TMDB_API_KEY |
themoviedb.org (free, non-commercial) — Opening section |
TICKETMASTER_API_KEY |
developer.ticketmaster.com (free, 5k calls/day) — Opening section |
TOMTOM_API_KEY |
developer.tomtom.com (free tier, ~2,500 requests/day) |
SERP_API_KEY |
serpapi.com (free plan is 250 searches/mo; paid from $50/mo for 5,000) |
APP_URL |
Your deployed app URL (for Twilio status callbacks) |
DATABASE_URL |
Postgres connection string (auto-set by Heroku; omit locally to use SQLite) |
PALMER_CONTACT_EMAIL |
Contact address sent in the User-Agent to the National Weather Service, which requires one |
3. Run locally
uvicorn main:app --reload4. Expose for Twilio (local dev)
ngrok http 8000
# Set Twilio webhook → https://<ngrok-url>/sms (POST)heroku create
heroku addons:create heroku-postgresql:essential-0
heroku config:set ANTHROPIC_API_KEY=... TWILIO_ACCOUNT_SID=... TWILIO_AUTH_TOKEN=... \
TWILIO_PHONE_NUMBER=... TAVILY_API_KEY=... GIPHY_API_KEY=... \
TOMTOM_API_KEY=... APP_URL=...
heroku config:set WEB_CONCURRENCY=1
git push heroku mainSet your Twilio SMS webhook to https://<your-app>.herokuapp.com/sms (POST).
Set your Twilio status callback to https://<your-app>.herokuapp.com/sms-status (POST).
Morning briefings are sent by APScheduler inside the web dyno (checked every 5 minutes) at each user's chosen local time — 7am by default, changeable by texting Palmer. No Heroku Scheduler job is required; if one exists running python send_morning.py, it's a harmless redundant backup (the per-user sent-date guard prevents double-sends).
GET /preview?phone=+15551234567 # generate morning briefing without sending
pip install -r requirements-dev.txt
pytest # ~1,400 tests, all offline, a few seconds
ruff check . # lintThe tests need no real keys. A few modules read their API key at import time, so set placeholder values (TAVILY_API_KEY=x, TOMTOM_API_KEY=x, SERP_API_KEY=x, the Twilio and Anthropic vars) or copy .env.example to .env. PALMER_NO_SCHEDULER=1 keeps the job loop from starting when main is imported in a shell or a one-off script. CI runs the same two commands on every push.
Design notes — the reasoning behind each subsystem and the production incidents that shaped it — live in CLAUDE.md.
WEB_CONCURRENCY=1is required. In-memory phone locks and APScheduler only work correctly in a single process.- Per-phone
threading.Lockserializes inbound messages so conversation history never interleaves under concurrent requests from the same number. - Source quality is one gate, applied at the search call (
sources.py). Every news fact and link Palmer sends — watch alerts, the morning briefing, Palmer Home, and conversational web search — goes throughdatafeeds._search_raw/_search, which filter before returning, so no surface can drift to its own standard. Four gates: a blocklist dropping press-release wires (prnewswire,globenewswire) and republishing aggregators (msn.com,biztoc,news.google.com) outright; a relevance floor that gives trusted sources 0.15 of slack, because Tavily's score measures query-text match and that is exactly what an SEO content farm is built to win; tier ordering fromtrusted_sources.json(tier 1 = wires and premier newsrooms plus.gov/.edu, tier 2 = mainstream and reputable specialists, tier 3 = everything else) sorting(tier, -score)so a wire report beats a higher-scoring blog; and corroboration requiring ≥ 2 distinct domains OR ≥ 1 tier-1 source before any unprompted alert fires. Palmer Home additionally drops tier 3 entirely — an untrusted row is worse than no row on a short curated list. Edittrusted_sources.jsonto add or remove sources; no code change needed. - Watches run every 30 minutes via APScheduler but only alert on major breaking developments — dated results from the last 12 hours, a strict criticality gate, a HEAD reachability check so a dead top link falls through to the next result, per-watch cooldown (default 4 hours), and dedup against the last alerted event.
- Traffic uses TomTom. Morning briefings auto-include a short city snapshot (
get_city_traffic): geocode → parallel Traffic Flow + Traffic Incidents in a city bounding box → Haiku drafts one natural line, or skips silently on failure. On demand, users can ask for city conditions or point-to-point drive times (get_travel_time, live traffic vs. free-flow). Landmark destinations (White House, Fenway, LAX) get resolved to street addresses by Sonnet before geocoding — TomTom's geocoder is a mapping API, not a search engine, and mis-ranks landmark names. A regular commute is saved withset_commute(geocoded once on save, with an optional leave time); the page's Commute card and the morning text are then routed for that departure via TomTomdepartAt, and say whether the number is a forecast for it or live traffic. - Price watches use SerpAPI Google Shopping (
shopping.py): user texts "watch these sneakers under $80" →add_price_watchtool saves toprice_watches→run_price_watchesjob runs twice a day on a cron (00:00 and 16:00 UTC — a cron rather than an interval so a deploy cannot reset the clock). Each run: SerpAPI Google Shopping query on the product name, Haiku picks the cheapest genuine match from the top candidates (guards against firing on unrelated accessories/refurbs), first successful check establishes a baseline silently, subsequent checks alert when the target is hit or the price has moved more than $2 in either direction (a flat bar, deliberately not a percentage, so a $12 grocery item and a $1,200 laptop are held to the same standard), then re-baseline. Cooldown defaults to 12h. Fails silently on any SerpAPI error — never surfaces "shopping tool failed" to the user. - Proactive outbound is scheduled: mornings at each user's local time (5-min tick, catch-up window, per-day guard), check-ins on a 2h tick but paced in days (
followup.GAP_DAYS; Haiku picks one subject from their threads, their team's game or the page's headlines by echoing it exactly, Sonnet drafts, 1–7pm window), and missing-data asks every 60 min for users onboarded without a city so mornings can target them (7-day cooldown, US-daytime UTC window;DATA_ASK_DRY_RUN=1to preview). - Reminder delivery uses
FOR UPDATE SKIP LOCKEDon Postgres — safe for multiple scheduler ticks, no double-sends. - Twilio webhook signatures (HMAC-SHA1) validated on every inbound request. All DB queries parameterized and scoped to phone number.
- Tool routing is hard:
get_weather→ NWS/Open-Meteo only,get_price→ CoinGecko/yfinance only, traffic tools → TomTom only, product price watches → SerpAPI only, web search → Tavily news mode. No overlap, no hallucinated data.
Copyright © 2026 Jeff Larson. All rights reserved. The source is published for reading and evaluation; see LICENSE.