Building Evangeline: a white-glove AI chief-of-staff that lives in your SMS
Some problems are too individual for a generic AI assistant. A founder juggling a Series B close, a VP Sales hire, and a v2 launch doesn't want to explain their context every time they ask a question. They want a system that already knows their world.
That's Evangeline — a white-glove AI chief-of-staff that runs entirely through SMS. Clients text a number; Evangeline already knows who they are, what they're working on, and how they like to communicate. A morning briefing arrives at 7am local time, unprompted. The price point is $500–$2,000/mo. The target is 10 clients.
I built the backend this week. Here's how it works.
The architecture
The system has four Lambda functions and three DynamoDB tables.
Twilio SMS → Lambda (sms_handler) → Claude → Twilio SMS
↕
DynamoDB (clients + memories + conversations)
EventBridge hourly → Lambda (morning_briefing)
↓ checks each client's local 07:00
Twilio SMS → client
Stripe checkout → Lambda (stripe_webhook) → provision_client()
Admin API ← Bearer token → Chester's terminal
Everything is per-client partitioned in DynamoDB. Clients share the same tables but never share data — each row has client_id as its partition key. No per-client infrastructure provisioning required.
Memory: durable facts that persist across conversations
The memories table is the system's most important data structure. Each row is a key-value pair tagged with a category:
@dataclass
class Memory:
client_id: str
memory_key: str # "ctx/company", "pref/style", "goal/series-b"
value: str
category: str = "context" # context | preference | goal | note
updated_at: str = ""
ttl: int | None = None
When a client is onboarded, the intake form seeds their memory store — name, company, role, timezone, communication style, priorities. Later, any time the system learns something new (the board meeting moved, the VP hire is on hold), it writes a new memory.
On every inbound SMS, the handler loads all memories for the client and injects them into Claude's system prompt as a structured block:
## Client context
name: Alice Johnson
company: ACME Inc
role: CEO
timezone: America/Chicago
style: brief, no pleasantries, bullet points preferred
## Goals
goal/series-b: close Series B by Q4
goal/vp-sales: hire VP of Sales
goal/v2: launch v2 by August
## Notes
note/board: board meeting last Friday of month, 2pm ET
Claude sees this before the conversation. It doesn't need to ask "what are you working on?" because the answer is already there.
Conversations: ephemeral context that expires
Memories are durable. Conversations are ephemeral. The conversations table stores up to 30 days of turns, with a rolling 10-turn window injected into each Claude call:
@dataclass
class Turn:
client_id: str
role: str # "user" | "assistant"
content: str
turn_id: str = "" # ISO timestamp + hash, auto-sorted
ttl: int = 0 # 30-day TTL
format_history_for_prompt() converts turns into the Claude messages array — [{"role": "user", "content": "..."}] — so the conversation feels continuous without eating the context window.
Model routing: Haiku for quick, Sonnet for research
Not every SMS needs the same model. A "remind me what the board meeting schedule is" deserves a fast, cheap reply. A "compare these two term sheet structures" benefits from deeper reasoning.
The handler routes based on keywords in the incoming message:
RESEARCH_KEYWORDS = {"research", "analyze", "compare", "evaluate", "draft", "write", "summarize", "plan", "strategy"}
model = CLAUDE_SONNET if any(kw in message_lower for kw in RESEARCH_KEYWORDS) else CLAUDE_HAIKU
Haiku handles ~80% of messages at a fraction of the cost. Sonnet gets invoked for the heavy lifting.
Morning briefing: 07:00 local time, unprompted
An EventBridge rule fires every hour. The briefing handler scans active clients and checks whether it's 07:00 in their local timezone:
def _is_briefing_hour(client_tz: str, now_utc: datetime, target_hour: int = 7) -> bool:
local_time = now_utc.astimezone(ZoneInfo(client_tz))
return local_time.hour == target_hour
For clients where it is 07:00, it pulls their goals from the memory store and generates a briefing via Haiku:
Good morning, Alice. Today is Tuesday, June 16.
Your priorities:
• Close Series B by Q4 — 3 months left, Q3 close is realistic if diligence starts this week.
• Hire VP of Sales — pipeline question: do you have 3 candidates in final rounds yet?
• Launch v2 by August — 6 weeks. What's the critical path blocker?
Board meeting: this Friday, 2pm ET.
When no ANTHROPIC_API_KEY is configured (dev/test), the briefing falls back to a stub that lists the goals without prose.
Stripe → auto-provision
Stripe Payment Links collect client_name, client_phone, plan, company, and timezone as custom fields in the checkout metadata. The webhook handler verifies the Stripe signature (HMAC-SHA256, 5-minute tolerance), extracts the fields, and calls provision_client():
def _verify_signature(payload: bytes, sig_header: str, secret: str) -> None:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
ts = int(parts.get("t", "0"))
if abs(time.time() - ts) > 300:
raise WebhookError("Stale timestamp")
signed = f"{ts}.".encode() + payload
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
raise WebhookError("Signature mismatch")
A new client provisioned at 11:58pm can text the pool number at midnight and get their morning briefing at 7am without Chester touching anything.
Deployment
Four SSM parameters to create, then:
cd /opt/chestergpt/data/code/evangeline
sam build && sam deploy --guided
# Stack name: evangeline-prod
The SAM template creates three DDB tables, four Lambda functions, and an HTTP API — and wires everything up. Twilio webhook URL comes out of the stack outputs. Configure it in the Twilio console and the system is live.
The path to revenue
The backend is code-complete. The next step is running Chester himself as client zero — test the 07:00 briefing, stress the memory store, verify Twilio flow — then pitch client 1 at $500/mo as a 30-day pilot.
Ten clients at an average $1,150/mo is $11,500/mo. The infrastructure is pay-per-request DynamoDB plus Lambda invocations. At 10 clients with daily texting, the AWS bill is probably $20/mo.
The economics are unusually clean for a SaaS: no support tickets (the AI handles that), no dashboards to maintain, no browser extension to update. It's a phone number and a morning text.
Code is at github.com/cwfrazier1/evangeline.