Cookbooks
Memory as an agent tool
Hand Memsy's search to the model as a callable tool so a multi-step agent recalls on demand instead of prefetching every turn.
The other cookbooks prefetch: they call search() on every turn and paste the results into the system prompt. That is the right default for a chat UI, where nearly every turn benefits from context.
It is the wrong default for an agent. A ten-step agent run — read a file, call an API, do arithmetic, write a summary — needs the user's history on maybe one of those steps. Prefetching pays for ten searches and burns prompt tokens on nine irrelevant ones.
The alternative is to make recall a tool the model calls when it decides it needs it. The integration point here is not a framework; it is the agent loop itself, so this recipe is framework-free — it works the same in OpenAI's SDK, Anthropic's, or any loop you wrote yourself.
Install dependencies
pip install memsy openaiSet your credentials
export MEMSY_BASE_URL="https://api.memsy.io/v1"
export MEMSY_API_KEY="msy_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export OPENAI_API_KEY="sk-..."Declare the tool
The description is the whole interface. The model reads it and nothing else when deciding whether to call — a vague one produces an agent that either never recalls or recalls on every step, which is prefetching with extra latency.
Note what the schema does not contain: there is no actor_id parameter. The model must not choose whose memories to read.
RECALL_TOOL = {
"type": "function",
"function": {
"name": "recall_memory",
"description": (
"Search this user's long-term memory for things they have "
"told you in past sessions — preferences, decisions, prior "
"context, and facts about their work. Call this when the "
"user refers to something from before ('like last time', "
"'the usual', 'what we decided'), when a personal "
"preference would change your answer, or when you are "
"missing context the user plainly expects you to have. Do "
"not call it for general knowledge or for facts already "
"stated in this conversation."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": (
"What to look for, as a natural-language "
"phrase. Search is semantic, so describe the "
"topic rather than guessing keywords."
),
}
},
"required": ["query"],
},
},
}Warning
Never put actor_id in a tool schema. The model would then be choosing whose memories to read, from a value it can hallucinate or be talked into by the user. Bind the actor in your executor, from your session — as below.
Implement the executor
The executor closes over the actor id, so the model supplies only the query. It also returns a plain string: tool results go straight back into the model's context, and a raw JSON dump of scores and ids costs tokens the model cannot use.
import os
from memsy import MemsyClient
from memsy.exceptions import MemsyError
memsy = MemsyClient(
base_url=os.environ["MEMSY_BASE_URL"],
api_key=os.environ["MEMSY_API_KEY"],
)
def make_recall(actor_id: str):
"""Bind the actor once; the model only ever supplies `query`."""
def recall(query: str) -> str:
try:
# No threshold: the default 0.0 returns the top 5 on any
# tier. A floor cuts noise, but the useful value is
# tier-dependent — see /docs/searching-memory#threshold.
found = memsy.search(query, actor_id=actor_id, limit=5)
except MemsyError:
# Tell the model the truth: the lookup failed, the user has
# no history. Otherwise it reports "you have never mentioned
# that" — a confident, wrong claim.
return "Memory lookup failed. Proceed without it."
if not found.results:
return "No relevant memories found."
return "\n".join(f"- {r.content}" for r in found.results)
return recallTip
"No relevant memories found." and "Memory lookup failed." must be different strings. They mean opposite things to the model, and collapsing them into one turns an outage into the assistant telling users they never said something they did.
Run the agent loop
A tool-calling loop runs until the model stops asking for tools. The bound iteration count is not optional — a model that keeps re-searching will otherwise spin.
import json
from openai import OpenAI
openai = OpenAI()
MAX_STEPS = 6
def run(actor_id: str, user_message: str) -> str:
recall = make_recall(actor_id)
messages = [
{
"role": "system",
"content": (
"You are a personal assistant with access to the user's "
"long-term memory. Use the recall_memory tool when past "
"context would change your answer."
),
},
{"role": "user", "content": user_message},
]
for _ in range(MAX_STEPS):
response = openai.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=[RECALL_TOOL]
)
message = response.choices[0].message
messages.append(message)
if not message.tool_calls:
return message.content or ""
for call in message.tool_calls:
# `tool_calls` is a union of function and custom tool calls,
# and only the function variant carries `.function`. Every
# call still needs a reply: an assistant message whose tool
# calls go unanswered is rejected on the next request.
if call.type != "function":
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": "Unsupported tool call.",
}
)
continue
# `required: ["query"]` is not enforced unless the tool is
# declared with `strict: True`, and a truncated stream can
# cut the JSON mid-object. Either way this must answer the
# call, not raise out of run() and take the whole turn.
try:
query = json.loads(call.function.arguments)["query"]
except (json.JSONDecodeError, KeyError, TypeError):
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": "Could not read the tool arguments.",
}
)
continue
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": recall(query),
}
)
# Out of steps. Returning a truthful failure beats returning the
# model's last half-finished thought as if it were an answer.
return "I could not finish that request."Record the turn
Recall being on demand does not make recording optional — an agent that only reads never accumulates anything to read. run() deliberately knows nothing about sessions, so the write lives in a thin wrapper around it.
def chat(actor_id: str, session_id: str, user_message: str) -> str:
answer = run(actor_id, user_message)
memsy.ingest(
[
EventPayload(
actor_id=actor_id,
session_id=session_id,
kind="user_message",
content=user_message,
),
EventPayload(
actor_id=actor_id,
session_id=session_id,
kind="assistant_message",
content=answer,
),
]
)
return answerEventPayload joins the MemsyClient import from the executor step:
from memsy import EventPayload, MemsyClientTip
Ingest the user's message and the final answer — not the intermediate tool calls. The recalled memories are already in Memsy; writing them back as new events re-ingests your own output and, over enough turns, drowns the original facts in restatements of themselves.
Tool vs. prefetch
| Prefetch | Tool | |
|---|---|---|
| Searches per run | One per turn | Zero to a few per run |
| Latency on turns that need memory | One search, in parallel with nothing | One extra model round trip, then the search |
| Latency on turns that don't | One wasted search | None |
| Prompt tokens | Memories in every prompt | Memories only when fetched |
| Failure mode | Irrelevant context distracts the model | Model doesn't call the tool when it should |
Neither dominates. Prefetch for a chat UI, tool for a multi-step agent — and note that "the model doesn't call it" is a real failure that shows up as an assistant plausibly claiming ignorance. Log tool-call rates; if recall is firing on under ~10% of turns in a memory-heavy product, the tool description is the thing to fix.
What to watch for
actor_idin the tool schema. The most dangerous version of this pattern. Bind it in the executor.- Returning JSON from the executor. Scores and ids are tokens the model cannot act on. Return prose.
- No iteration bound. A loop with no
MAX_STEPScan spin until your rate limit stops it. - Conflating "no results" with "lookup failed". Different strings, always.
- Re-ingesting recalled memories. Record what the user said and what you answered, not what you looked up.
Complete code
The whole agent, assembled from the steps above. Imports are collected at the top; every other line is exactly as it appears in the walkthrough.
# agent.py
import json
import os
from memsy import EventPayload, MemsyClient
from memsy.exceptions import MemsyError
from openai import OpenAI
memsy = MemsyClient(
base_url=os.environ["MEMSY_BASE_URL"],
api_key=os.environ["MEMSY_API_KEY"],
)
def make_recall(actor_id: str):
"""Bind the actor once; the model only ever supplies `query`."""
def recall(query: str) -> str:
try:
# No threshold: the default 0.0 returns the top 5 on any
# tier. A floor cuts noise, but the useful value is
# tier-dependent — see /docs/searching-memory#threshold.
found = memsy.search(query, actor_id=actor_id, limit=5)
except MemsyError:
# Tell the model the truth: the lookup failed, the user has
# no history. Otherwise it reports "you have never mentioned
# that" — a confident, wrong claim.
return "Memory lookup failed. Proceed without it."
if not found.results:
return "No relevant memories found."
return "\n".join(f"- {r.content}" for r in found.results)
return recall
RECALL_TOOL = {
"type": "function",
"function": {
"name": "recall_memory",
"description": (
"Search this user's long-term memory for things they have "
"told you in past sessions — preferences, decisions, prior "
"context, and facts about their work. Call this when the "
"user refers to something from before ('like last time', "
"'the usual', 'what we decided'), when a personal "
"preference would change your answer, or when you are "
"missing context the user plainly expects you to have. Do "
"not call it for general knowledge or for facts already "
"stated in this conversation."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": (
"What to look for, as a natural-language "
"phrase. Search is semantic, so describe the "
"topic rather than guessing keywords."
),
}
},
"required": ["query"],
},
},
}
openai = OpenAI()
MAX_STEPS = 6
def run(actor_id: str, user_message: str) -> str:
recall = make_recall(actor_id)
messages = [
{
"role": "system",
"content": (
"You are a personal assistant with access to the user's "
"long-term memory. Use the recall_memory tool when past "
"context would change your answer."
),
},
{"role": "user", "content": user_message},
]
for _ in range(MAX_STEPS):
response = openai.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=[RECALL_TOOL]
)
message = response.choices[0].message
messages.append(message)
if not message.tool_calls:
return message.content or ""
for call in message.tool_calls:
# `tool_calls` is a union of function and custom tool calls,
# and only the function variant carries `.function`. Every
# call still needs a reply: an assistant message whose tool
# calls go unanswered is rejected on the next request.
if call.type != "function":
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": "Unsupported tool call.",
}
)
continue
# `required: ["query"]` is not enforced unless the tool is
# declared with `strict: True`, and a truncated stream can
# cut the JSON mid-object. Either way this must answer the
# call, not raise out of run() and take the whole turn.
try:
query = json.loads(call.function.arguments)["query"]
except (json.JSONDecodeError, KeyError, TypeError):
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": "Could not read the tool arguments.",
}
)
continue
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": recall(query),
}
)
# Out of steps. Returning a truthful failure beats returning the
# model's last half-finished thought as if it were an answer.
return "I could not finish that request."
def chat(actor_id: str, session_id: str, user_message: str) -> str:
answer = run(actor_id, user_message)
memsy.ingest(
[
EventPayload(
actor_id=actor_id,
session_id=session_id,
kind="user_message",
content=user_message,
),
EventPayload(
actor_id=actor_id,
session_id=session_id,
kind="assistant_message",
content=answer,
),
]
)
return answerpip install memsy openai
export MEMSY_BASE_URL="https://api.memsy.io/v1"
export MEMSY_API_KEY="msy_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export OPENAI_API_KEY="sk-..."
python -c 'from agent import chat; print(chat("user_1", "sess_1", "What did we decide about pricing?"))'Next steps
- LangChain — the same idea inside an LCEL chain, using prefetch.
- MCP server — expose Memsy to any MCP-speaking client without writing a tool schema at all.
- Searching memory — thresholds and result shape.

