OpenAI Agents SDK
One command writes memory into an OpenAI Agents SDK project as code you own - recall before the model, remember after the run, and tools the model calls - with the end user set by your code, never the model.
twinmemory-cli adds memory to an OpenAI Agents SDK project the way shadcn adds a component: it writes one file
into your project, and from then on that file is yours to read and edit. It is on PyPI and npm Soon: this page
describes version 0.1.0, and until it is published, a function tool over REST or
the hosted MCP server does the job.
uvx twinmemory-cli init openai-agents # a project you have
uvx twinmemory-cli create openai-agents my-agent # a new one, with memory wired in
uvx twinmemory-cli doctor # is it all set up?openai-agents 0.3 or newer. The file uses the Python SDK.
What init does
Before it writes anything, init openai-agents checks three things, and any of them can stop it with nothing
changed:
- it is in a Python project (or a JavaScript one for
npx); - the project uses the OpenAI Agents SDK: it is a dependency, or your code imports it. If not,
initasks before adding it, and--yesadds it without asking. In Python, a project with anagentspackage of its own is warned: the SDK is imported asagentstoo, and the two would clash; - the installed SDK is new enough. If not, it says what to upgrade.
Then it:
- writes
memory.py(ormemory.ts) beside the file that builds yourAgent:Agent(...)in Python,new Agent(...)orAgent.create(...)in TypeScript. With several, it asks which (or--dir); with none, it writes into your project's package. An existing file is replaced only with--force; - adds what the file imports, with your project's own tool: uv, poetry, pdm or
requirements.txt; npm, pnpm, yarn or bun; - sets up your key. Pass
--key tm_..., or paste it when asked. Enter skips, and an emptyTWIN_MEMORY_API_KEY=waits in.envfor later. A key it is given is checked, and a key that only reads is called out, since saving needs one that writes.
| Flag | What it does |
|---|---|
--nodes-only | recall and remember, no tools |
--tools-only | the tools, no recall or remember |
--fast | searches skip the model filter: free and quicker |
--key, --base-url | your key, and memory's address, written into .env |
--skip-key | do not ask for a key |
--dir, --file | where the file goes, and what it is called |
--force | replace a file already there |
--no-install | print the install command instead of running it |
--dry-run | make every check, say what would happen, change nothing |
--yes | ask nothing |
--graph and --async are LangGraph's, and refused here: the file is async already.
What it writes
| Python | TypeScript | What it is |
|---|---|---|
recall | recall | the run's model input filter: searches once a turn, with the user's latest message, and adds what it finds to the instructions |
remember(result) | remember(result) | saves the turn a run answered - the user's message and the final output - under the agent's name |
MemoryContext(user_id, session_id) | MemoryContext { userId, sessionId? } | the run's context: whose memory, and the conversation saves are grouped by |
MEMORY_TOOLS | MEMORY_TOOLS | memory_search and memory_read, for a model that looks things up itself |
MEMORY_WRITE_TOOLS | MEMORY_WRITE_TOOLS | the same and memory_add, to let the model save as well |
Its settings are at the top of the file: FAST, STRICT, MAX_CHARS and TIMEOUT.
Wire it in
Use recall and remember, the tools, or both.
from agents import Agent, RunConfig, Runner
from app.memory import MEMORY_TOOLS, MemoryContext, recall, remember
agent = Agent(name="Assistant", instructions="You are a helpful assistant.", tools=[*MEMORY_TOOLS])
result = await Runner.run(
agent,
"Where do I live?",
context=MemoryContext(user_id="dana", session_id="conv-7"),
run_config=RunConfig(call_model_input_filter=recall),
)
await remember(result)Already using a call_model_input_filter? Run both: data.model_data = await recall(data), then yours.
- Whose memory is the run's context, set by your code:
user_idin Python,userIdin TypeScript. Any context with that field will do. The SDK never sends the context to the model, and the tools take the user from it, not from their arguments, so a model cannot ask for anybody else's memory. See End users. - Recall runs once a turn. The filter runs before every model call, so in a tool loop or after a handoff it reuses what it found at the start of the turn, for that run. Two turns in flight for one user keep their own.
- Memory goes into the instructions, never into the input, so it is not written into a
Session, the Conversations API or the history you keep. It works the same with each, and the agent a run hands off to gets it too. remember(result)saves after the run. For a stream, call it once the stream has finished. A run paused for an approval saves nothing; save the run that resumes it. Saving the same result twice replaces, rather than adds.- A long message - a pasted email - is searched by its last 2,000 characters, the most a search takes.
- Saving needs a key that writes.
recalland the read tools work with a read key.
When memory is down
The file fails open: with STRICT = False, the default, memory being unreachable, slow or failing costs a turn
its memory, not the turn. recall adds nothing, remember skips the save, the tools tell the model memory is
unavailable, and each is logged. When a tool fails because of what the model asked - an argument the service
rejects - the model is told why, and can try again. A wrong key still raises - that is a mistake to fix. In
Python, a wrong key in a tool reaches you as the SDK's UserError, with memory's error as its cause. Set
STRICT = True to raise on everything.
create
create openai-agents <name> makes a new project with memory already wired in: an agent, a terminal chat, and the
memory file. It takes init's presets and key flags, and --model, the model by the SDK's name (MODEL in
.env; empty, the SDK's default). Fill in OPENAI_API_KEY in .env too.
uvx twinmemory-cli create openai-agents my-agent --key tm_...
cd my-agent
uv run python -m my_agent.chat --user dana # talk to itdoctor
doctor checks the memory file, the dependencies and their installed versions, the key (with one free request),
and that .env is not committed. Each failure says how to fix it.
Through the hosted MCP server
MCPServerStreamableHttp connects an agent to a remote MCP server and sends the headers you give it. Point it at
the hosted server with a read key and the end user's id. The agent then sees every
memory tool, such as memory_search, memory_grep and memory_read. Nothing recalls before the
model, and one connection serves one end user.
import asyncio
import os
from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp
async def main() -> None:
async with MCPServerStreamableHttp(
name="twin memory",
params={
"url": "https://api.twinmemory.dev/v1/mcp",
"headers": {
"Authorization": f"Bearer {os.environ['TWIN_READ_KEY']}",
"X-Twin-Account": "user_42",
},
"timeout": 30,
},
cache_tools_list=True,
) as memory:
agent = Agent(
name="Assistant",
instructions="Use the memory tools before you answer a question about the user's past.",
mcp_servers=[memory],
)
result = await Runner.run(agent, "Where do I live now?")
print(result.final_output)
asyncio.run(main())With a read and write key instead, the server also lists memory_add, and the agent can write to the user's
memory. Give it that key only if you mean it to.
Until it is published
A function tool that calls search over REST gives the model the user's memory, and your backend adds the turn after the run. The tool is made per end user, so the user id comes from your code and never from the model.
import asyncio
import os
import requests
from agents import Agent, Runner, function_tool
TWIN = "https://api.twinmemory.dev/v1/memory"
def twin(key: str, user_id: str, route: str, body: dict) -> dict | None:
r = requests.post(
f"{TWIN}/{route}",
headers={"Authorization": f"Bearer {key}", "X-Twin-Account": user_id},
json=body,
timeout=60,
)
if r.status_code == 404: # no memory for this end user yet
return None
r.raise_for_status()
return r.json()
def memory_tool(user_id: str):
@function_tool
def search_memory(question: str) -> str:
"""Search what this user has said before. Returns dated facts and passages, each with its address.
Args:
question: The question to look up, in plain words.
"""
brief = twin(os.environ["TWIN_READ_KEY"], user_id, "search", {"query": question[-2000:]}) or {}
lines = [*brief.get("known", []), *(f"- {f['text']} [{f['source']}]" for f in brief.get("facts", []))]
lines += [f"[{h['path']}] {h['text']}" for h in brief.get("hits", [])]
return "\n".join(lines) or "Memory holds nothing about this."
return search_memory
async def reply(user_id: str, conversation: str, turn: int, message: str) -> str:
agent = Agent(
name="Assistant",
instructions="Search memory before you answer a question about the user's past. Cite addresses.",
tools=[memory_tool(user_id)],
)
result = await Runner.run(agent, message)
twin(os.environ["TWIN_MEMORY_API_KEY"], user_id, "add", {"items": [
{"id": f"{conversation}-{turn:04d}", "session": conversation, "role": "user", "text": message},
{"id": f"{conversation}-{turn + 1:04d}", "session": conversation, "role": "assistant",
"author": "Assistant", "text": result.final_output},
]})
return result.final_output
print(asyncio.run(reply("user_42", "conv-7", 12, "Where do I live now?")))The SDK reads the function's type hints and docstring to build the tool's schema. The search uses a read key; the add uses the read and write key, outside the tool.
Checked against openai.github.io/openai-agents-python,
openai.github.io/openai-agents-js, openai-agents 0.24.0 and
@openai/agents 0.21.0 on 2026-10-23.