Twilio
Give an assistant that people text a memory of each of them. Twilio sends every message to your webhook, for SMS and for WhatsApp alike; your server puts what is known about the sender in front of Claude and answers in the same request. The next message starts knowing this one.
Install
pip install fastapi uvicorn python-multipart anthropic twilio geniffyuv add fastapi uvicorn python-multipart anthropic twilio geniffySet GENIFFY_API_KEY from API keys in the Geniffy app and ANTHROPIC_API_KEY. From Twilio, set
TWILIO_AUTH_TOKEN, and set TWILIO_WEBHOOK_URL to the address you give Twilio below, exactly as you give
it: Twilio signs every request over that address.
Your server
import os
from anthropic import AsyncAnthropic
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request, Response
from geniffy import AsyncGeniffy
from twilio.request_validator import RequestValidator
from twilio.twiml.messaging_response import MessagingResponse
geniffy = AsyncGeniffy() # reads GENIFFY_API_KEY
claude = AsyncAnthropic() # reads ANTHROPIC_API_KEY
twilio = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"])
app = FastAPI()
recent: dict[str, list] = {} # each person's last few turns, for "yes" and "that one"
def space_for(number: str) -> str:
"""The sender, by the digits of their number: the same person whether they text or use WhatsApp."""
digits = "".join(ch for ch in number if ch.isdigit())
if not digits:
raise HTTPException(400, "This message has no sender number.")
return f"user_{digits}"
@app.post("/twilio")
async def receive(request: Request, background: BackgroundTasks):
form = {k: str(v) for k, v in (await request.form()).items()}
if not twilio.validate(os.environ["TWILIO_WEBHOOK_URL"], form, request.headers.get("X-Twilio-Signature", "")):
raise HTTPException(403)
text, reply = (form.get("Body") or "").strip(), MessagingResponse()
if text:
mem = geniffy.space(space_for(form.get("From", "")))
turn = {"role": "user", "content": text}
past = recent.setdefault(form["From"], [])
response = await claude.messages.create(
model="claude-opus-5-5",
max_tokens=600,
system=f"You are a helpful assistant over text messages. Keep replies short.\n\n"
f"<memory>\n{await mem.context(text)}\n</memory>",
messages=[*past[-10:], turn],
)
said = "".join(block.text for block in response.content if block.type == "text")
reply.message(said)
past += [turn, {"role": "assistant", "content": said}]
background.add_task(mem.memories.add, messages=[turn, {"role": "assistant", "content": said}])
return Response(str(reply), media_type="application/xml")Run it with uvicorn server:app, somewhere Twilio can reach over HTTPS.
Twilio waits up to 15 seconds for the answer, so the reply goes back in the same request and the exchange is saved after it has gone. The last few turns of each conversation are kept in memory, which is right for one process; with more than one, keep them where every process can see them.
In Twilio
Set the address where a message comes in to https://your-server/twilio (HTTP POST):
- SMS: on your phone number, under Messaging, A message comes in.
- WhatsApp: on your WhatsApp sender, or the WhatsApp Sandbox while you build, under When a message comes in.
Each person is the space named by their number's digits, so the same person is one memory over SMS and WhatsApp. If you know them as customers, use your customer id instead. When nothing is known about the sender, the memory says so in one sentence, so Claude says it doesn't know instead of guessing.