Cookbooks
FastAPI support agent
An async FastAPI service that recalls a customer's history from Memsy before answering, with ingest moved off the request path.
Support agents live or die on continuity. A customer who explained their setup last Tuesday should not explain it again today — and if they do, they will tell you about it in the CSAT score.
This recipe builds a FastAPI endpoint that answers a support question with the customer's own history in the prompt. The integration point is the async runtime: FastAPI serves requests on an event loop, so this uses AsyncMemsyClient throughout. The synchronous client would block that loop for the duration of every Memsy call, stalling every other request the worker is handling.
Install dependencies
pip install memsy fastapi uvicorn openaiPython 3.10 or later.
Set your credentials
export MEMSY_BASE_URL="https://api.memsy.io/v1"
export MEMSY_API_KEY="msy_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export OPENAI_API_KEY="sk-..."Own the client's lifecycle with a lifespan handler
AsyncMemsyClient wraps an httpx.AsyncClient with its own connection pool. Build it once at startup and close it at shutdown — a client created per request opens a fresh pool each time and leaks the sockets when the request ends.
# main.py
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from memsy import AsyncMemsyClient
def _required(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Missing required env var: {name}")
return value
@asynccontextmanager
async def lifespan(app: FastAPI):
# Fail at boot, not on the first customer request.
app.state.memsy = AsyncMemsyClient(
base_url=_required("MEMSY_BASE_URL"),
api_key=_required("MEMSY_API_KEY"),
)
try:
yield
finally:
await app.state.memsy.close()
app = FastAPI(lifespan=lifespan)Tip
AsyncMemsyClient also works as an async context manager (async with AsyncMemsyClient(...) as client:) for scripts and tests. In a long-lived server the lifespan handler is the equivalent, scoped to the process rather than a block.
Validate the request at the boundary
The customer id decides whose memories get read. Validate it as strictly as any other identifier — and take it from your authenticated session, not from the body, once you have one.
from pydantic import BaseModel, Field
class AskRequest(BaseModel):
question: str = Field(min_length=1, max_length=4000)
ticket_id: str = Field(min_length=1, max_length=64)
class AskResponse(BaseModel):
answer: str
recalled: intWrite the endpoint
import logging
import os
from fastapi import BackgroundTasks, Depends, HTTPException, Request
from memsy import EventPayload
from memsy.exceptions import MemsyError, RateLimitExceeded
from openai import AsyncOpenAI
log = logging.getLogger(__name__)
openai = AsyncOpenAI()
# A floor on relevance, so "no history" is an outcome the model can see.
# Leave at 0.0 on the free tier — unranked scores rarely clear 0.1, and a
# higher floor filters out everything. Raise to 0.3-0.5 once you are on a
# reranked tier. Configuration rather than a constant for exactly that
# reason; see "Choosing a threshold" below.
RECALL_THRESHOLD = float(os.environ.get("MEMSY_RECALL_THRESHOLD", "0.0"))
# Replace with your real auth dependency. The point is that the customer id
# is derived server-side; a body-supplied one would let any caller read any
# customer's history.
async def current_customer_id(request: Request) -> str:
customer_id = request.headers.get("X-Customer-Id")
if not customer_id:
raise HTTPException(status_code=401, detail="Not authenticated")
return customer_id
@app.post("/ask", response_model=AskResponse)
async def ask(
body: AskRequest,
background: BackgroundTasks,
request: Request,
customer_id: str = Depends(current_customer_id),
) -> AskResponse:
memsy: AsyncMemsyClient = request.app.state.memsy
# 1. Recall. A failure here degrades the answer; it must not 500 the
# request, because an answer without memory still helps the customer.
try:
recalled = await memsy.search(
body.question,
actor_id=customer_id,
limit=5,
threshold=RECALL_THRESHOLD,
)
memories = [r.content for r in recalled.results]
except RateLimitExceeded:
memories = []
except MemsyError:
# Full context stays server-side; the customer sees a normal answer.
log.exception("memsy recall failed")
memories = []
context = "\n".join(f"- {m}" for m in memories) or "(no prior history)"
# 2. Ground.
completion = await openai.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"You are a support agent. The customer's prior history is "
"below — use it instead of asking them to repeat "
"themselves. If it contradicts the current question, "
"trust the current question.\n\n"
f"History:\n{context}"
),
},
{"role": "user", "content": body.question},
],
)
answer = completion.choices[0].message.content or ""
# 3. Record — after the response is sent.
background.add_task(
_record_turn, memsy, customer_id, body.ticket_id, body.question, answer
)
return AskResponse(answer=answer, recalled=len(memories))Record the turn in the background
BackgroundTasks runs after the response is written, so the write costs the customer nothing. FastAPI awaits async background tasks on the same event loop — which is another reason the async client matters here.
async def _record_turn(
memsy: AsyncMemsyClient,
customer_id: str,
ticket_id: str,
question: str,
answer: str,
) -> None:
try:
await memsy.ingest(
[
EventPayload(
actor_id=customer_id,
session_id=f"ticket_{ticket_id}",
kind="user_message",
content=question,
),
EventPayload(
actor_id=customer_id,
session_id=f"ticket_{ticket_id}",
kind="assistant_message",
content=answer,
),
]
)
except MemsyError:
# The response has already gone out. Raising here would only produce
# an unhandled-task traceback in the logs, so log it deliberately.
log.exception("memsy ingest failed")Warning
Background tasks die with the process. If a deploy restarts the worker between the response and the write, that turn is never recorded. For a support system where the transcript is the product, write to your own database synchronously and ingest to Memsy from a durable queue instead.
Run it
uvicorn main:app --reload --port 8000curl -X POST http://localhost:8000/ask \
-H "Content-Type: application/json" \
-H "X-Customer-Id: cust_42" \
-d '{"question": "Is my export still failing?", "ticket_id": "T-1001"}'The first call returns "recalled": 0 — there is nothing on file yet. Ask a follow-up a minute later and the count climbs as extraction completes.
Tip
If recalled is still 0 after several exchanges, check MEMSY_RECALL_THRESHOLD before suspecting extraction: a floor set too high for your tier filters out every result and looks identical to having no history. See Choosing a threshold.
Choosing a threshold
The default threshold=0.0 returns the top limit results however weakly they
match, so a question with no relevant history still pulls five loosely-related
memories into the prompt. In a support context that produces confidently wrong
answers about the wrong ticket. A floor makes "no relevant history" an outcome
the model can see.
The catch is that the useful value depends on your tier, because the scores themselves are on different scales:
| Tier | Score range | Sensible floor |
|---|---|---|
| Free (no reranking) | Raw scores cluster in 0.0–0.1, even for strong matches | 0.0–0.05 |
| Pro and above (Cohere reranking) | Normalised to 0.0–1.0 | 0.3–0.5 |
Warning
Setting 0.3 on the free tier filters out everything — free-tier scores
rarely reach it, so every recall returns empty and the agent looks like it has
amnesia rather than a misconfigured floor. That is why MEMSY_RECALL_THRESHOLD
defaults to 0.0 above: raise it once you are on a reranked tier and have
looked at your own score distribution.
Scores are not comparable across queries either — 0.6 on one question does not
mean what it means on another. Treat this as a tuning knob, not a quality bar.
See Searching memory for the full guidance.
What to watch for
- The sync
MemsyClientin an async handler. It blocks the event loop for the whole HTTP call. Every concurrent request on that worker waits. This is the single most common version of this mistake. - A client per request. Build it in
lifespan, read it fromapp.state. - Letting a recall failure 500 the request. Memory is an enhancement here, not a dependency. Degrade to no context.
- Returning the exception text to the caller. Log the detail server-side; return a generic message. Memsy error details can name internal hosts.
Complete code
The whole service, assembled from the steps above. The imports are collected at the top; every other line is exactly as it appears in the walkthrough.
# main.py
import logging
import os
from contextlib import asynccontextmanager
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request
from memsy import AsyncMemsyClient, EventPayload
from memsy.exceptions import MemsyError, RateLimitExceeded
from openai import AsyncOpenAI
from pydantic import BaseModel, Field
def _required(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Missing required env var: {name}")
return value
@asynccontextmanager
async def lifespan(app: FastAPI):
# Fail at boot, not on the first customer request.
app.state.memsy = AsyncMemsyClient(
base_url=_required("MEMSY_BASE_URL"),
api_key=_required("MEMSY_API_KEY"),
)
try:
yield
finally:
await app.state.memsy.close()
app = FastAPI(lifespan=lifespan)
class AskRequest(BaseModel):
question: str = Field(min_length=1, max_length=4000)
ticket_id: str = Field(min_length=1, max_length=64)
class AskResponse(BaseModel):
answer: str
recalled: int
log = logging.getLogger(__name__)
openai = AsyncOpenAI()
# A floor on relevance, so "no history" is an outcome the model can see.
# Leave at 0.0 on the free tier — unranked scores rarely clear 0.1, and a
# higher floor filters out everything. Raise to 0.3-0.5 once you are on a
# reranked tier. Configuration rather than a constant for exactly that
# reason; see "Choosing a threshold" below.
RECALL_THRESHOLD = float(os.environ.get("MEMSY_RECALL_THRESHOLD", "0.0"))
# Replace with your real auth dependency. The point is that the customer id
# is derived server-side; a body-supplied one would let any caller read any
# customer's history.
async def current_customer_id(request: Request) -> str:
customer_id = request.headers.get("X-Customer-Id")
if not customer_id:
raise HTTPException(status_code=401, detail="Not authenticated")
return customer_id
@app.post("/ask", response_model=AskResponse)
async def ask(
body: AskRequest,
background: BackgroundTasks,
request: Request,
customer_id: str = Depends(current_customer_id),
) -> AskResponse:
memsy: AsyncMemsyClient = request.app.state.memsy
# 1. Recall. A failure here degrades the answer; it must not 500 the
# request, because an answer without memory still helps the customer.
try:
recalled = await memsy.search(
body.question,
actor_id=customer_id,
limit=5,
threshold=RECALL_THRESHOLD,
)
memories = [r.content for r in recalled.results]
except RateLimitExceeded:
memories = []
except MemsyError:
# Full context stays server-side; the customer sees a normal answer.
log.exception("memsy recall failed")
memories = []
context = "\n".join(f"- {m}" for m in memories) or "(no prior history)"
# 2. Ground.
completion = await openai.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"You are a support agent. The customer's prior history is "
"below — use it instead of asking them to repeat "
"themselves. If it contradicts the current question, "
"trust the current question.\n\n"
f"History:\n{context}"
),
},
{"role": "user", "content": body.question},
],
)
answer = completion.choices[0].message.content or ""
# 3. Record — after the response is sent.
background.add_task(
_record_turn, memsy, customer_id, body.ticket_id, body.question, answer
)
return AskResponse(answer=answer, recalled=len(memories))
async def _record_turn(
memsy: AsyncMemsyClient,
customer_id: str,
ticket_id: str,
question: str,
answer: str,
) -> None:
try:
await memsy.ingest(
[
EventPayload(
actor_id=customer_id,
session_id=f"ticket_{ticket_id}",
kind="user_message",
content=question,
),
EventPayload(
actor_id=customer_id,
session_id=f"ticket_{ticket_id}",
kind="assistant_message",
content=answer,
),
]
)
except MemsyError:
# The response has already gone out. Raising here would only produce
# an unhandled-task traceback in the logs, so log it deliberately.
log.exception("memsy ingest failed")Run it:
pip install memsy fastapi uvicorn openai
export MEMSY_BASE_URL="https://api.memsy.io/v1"
export MEMSY_API_KEY="msy_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export OPENAI_API_KEY="sk-..."
uvicorn main:app --reload --port 8000Next steps
- Async client — the full
AsyncMemsyClientsurface. - Error handling — every typed exception and when it fires.
- Retries — the built-in 429 backoff, and when to override it.

