Cortadel × Pydantic AI
Cortadel is self-hosted long-term temporal graph memory for AI agents — a
bi-temporal graph store with hybrid BM25 + vector search. This package plugs it into
Pydantic AI as a capability, pydantic-ai's own primary extension
point. One object added to your Agent gives it memory tools and automatic recall and
persistence, so the agent remembers a user across sessions without you threading state through
your application.
Install
pip install cortadel-pydantic-ai
uv add cortadel-pydantic-ai
It depends on pydantic-ai-slim so you keep choosing your own model provider extras; an existing
pydantic-ai install satisfies it too.
Quickstart
import asyncio
from pydantic_ai import Agent
from cortadel_pydantic_ai import CortadelMemory
memory = CortadelMemory(
base_url="http://localhost:3001", # or https://app.cortadel.ai
user_id="e2e-quickstart", # whose memories these are
)
agent = Agent(
"openai:gpt-5",
instructions="You are a helpful assistant.",
capabilities=[memory],
)
async def main() -> None:
# Turn 1 — nothing is known yet; the turn is stored on the way out.
first = await agent.run("I'm allergic to peanuts, and I prefer metric units.")
print(first.output)
# Turn 2, a brand-new run with no message history: the allergy is recalled from Cortadel
# and injected into the prompt before the model sees the question.
second = await agent.run("Suggest a dessert for me.")
print(second.output)
await memory.aclose()
asyncio.run(main())
examples/ has two runnable scripts: quickstart.py (the above, with commentary) and
multi_user.py (per-user scoping through deps).
What you get
Adding CortadelMemory to capabilities=[...] contributes three things at once.
1. Memory tools the agent can call
A FunctionToolset (id cortadel-memory) with two tools:
| Tool | Signature | What it does |
|---|---|---|
search_memory |
(query: str, top_k: int = 10) -> str |
Searches the user's long-term memory and returns the matching facts. |
add_memories |
(text: str) -> str |
Saves a durable fact, reporting whether it was stored, deduplicated, or superseded. |
Want only the tools, without the automatic behaviour? Use the toolset on its own:
from cortadel_pydantic_ai import cortadel_toolset
agent = Agent("openai:gpt-5", toolsets=[cortadel_toolset(user_id="e2e-alice")])
The top_k the model sees defaults to 10 on a standalone cortadel_toolset(...) — matching
the Cortadel SDK's own SearchOptions.top_k — and to the capability's top_k (5) when the
toolset comes from CortadelMemory, so one knob governs both halves of that setup. Either way the
model may override it per call, and the value is clamped to the server's 1–50 range.
2. Automatic recall before the model call
CortadelMemory.get_instructions() contributes a dynamic instruction that searches Cortadel with
the run's prompt and injects the hits into the prompt. Two properties are worth calling out:
- One search per run, not per step.
for_run()gives each run its own copy of the capability to cache the recall on, so a five-step tool loop still makes exactly one Cortadel call. - Nothing is injected when there is nothing to say. With no hits — or an unreachable server —
the instruction returns
None, which pydantic-ai drops entirely. No empty section, no wasted tokens.
Recalled memories are injected as instructions, never as conversation parts, so they do not
turn into a fake user or system turn in the transcript. pydantic-ai builds the instructions block
freshly for each request from the current run's instruction parts, so feeding
result.all_messages() back as message_history= on the next turn sends exactly one memory block
— the newly recalled one — rather than accumulating a copy per turn.
One caveat worth knowing if you persist transcripts: the rendered instructions string is still
recorded on each historical ModelRequest.instructions for the record. It is not re-sent to the
model, but it does mean recalled memory text is present in a serialized all_messages() dump.
3. Automatic persistence after the turn
after_run() converts the run's new messages (result.new_messages(), not all_messages())
into a Cortadel conversation and sends it with add_conversation, which extracts durable facts
server-side. Only user prompts and assistant text are sent; tool calls, tool returns and system
prompts are mechanics, not facts.
That write is awaited by default (await_persist=True), unlike most Cortadel integrations:
after_run() is the last hook in a pydantic-ai run and the framework owns no background task
pool, so in the ordinary asyncio.run(main()) shape a fire-and-forget task is cancelled the
moment main() returns and the turn is silently lost. Set await_persist=False to take the write
off the critical path — then await memory.aclose() before shutdown, which drains whatever is
still in flight.
Failure is never fatal, by default
Memory is an enhancement, not a dependency. Out of the box, if Cortadel is unreachable, recall injects nothing, persistence is skipped, the tools tell the model that memory is unavailable, and the run continues. Cancellation is never swallowed.
Two independent knobs control what you see:
on_erroris an observer: a callback that receives every failure. It fires whether or not the failure is also raised, and its own exceptions are logged rather than propagated. With no callback set, a swallowed failure logs a warning on thecortadel_pydantic_ailogger instead.raise_on_erroris the policy:False(the default) degrades,Truelets the failure propagate into the run. They compose in that order — the callback fires, then the failure is re-raised if you asked for it.
raise_on_error cannot apply to a write that has already been backgrounded with
await_persist=False: there is no caller left to raise into, so such a failure only reaches
on_error.
Configuration
base_url and user_id — and only those two — may be passed positionally, in that order, so
CortadelMemory("http://localhost:3001", "e2e-alice") works and reads the way
CortadelClient(base_url, user_id, ...) does in the Cortadel SDK itself. Every other option is
keyword-only, which is what makes this list safe to extend: a new option can never take over a
positional slot that used to mean something else. cortadel_toolset(...) is keyword-only
throughout — it is a builder rather than a client-shaped entry point.
| Option | Type | Default | Meaning |
|---|---|---|---|
base_url |
str |
$CORTADEL_BASE_URL, else http://localhost:3001 |
Cortadel server URL. Hosted: https://app.cortadel.ai. |
user_id |
str | (ctx) -> str |
$CORTADEL_USER_ID |
Whose memories these are. A callable derives it per run, normally from ctx.deps. Required. |
api_key |
str | None |
$CORTADEL_API_KEY |
Bearer token. Omit when the server has auth disabled. |
app_name |
str |
"cortadel-pydantic-ai" |
Identifies this integration for access logging; also labels stored memories. |
timeout |
float |
100.0 |
Per-client HTTP timeout, in seconds. |
tools |
bool |
True |
Expose search_memory / add_memories to the model. |
recall |
bool |
True |
Search and inject memories before each run. |
persist |
bool |
True |
Store the turn after each run. |
await_persist |
bool |
True |
Wait for that write before the run returns. True because after_run() is the last hook in a run — see above. |
top_k |
int |
5 |
Maximum memories recalled per run (1–50), and the default top_k the search_memory tool offers the model. cortadel_toolset(...) on its own defaults to 10. |
search_mode |
str |
"hybrid" |
hybrid (BM25 + vector), text, or vector. |
rerank |
bool |
False |
Rerank with the server's cross-encoder. More accurate, slower. |
memory_type |
str | None |
None |
Restrict recall to episodic, semantic, or procedural. |
scope_recall_to_session |
bool |
False |
Restrict recall to the current session. Off by default — scoping to a session would hide everything learned in earlier conversations. |
instructions_header |
str |
see DEFAULT_INSTRUCTIONS_HEADER |
Preamble above the recalled memories. |
session_id |
str | (ctx) -> str | None | None |
ctx.conversation_id |
Groups stored facts into a session. |
project |
str | None |
None |
Project scope recorded on stored facts (e.g. a repo name). |
tags |
Sequence[str] | None |
None |
Tags applied to every stored fact. |
client_factory |
(user_id) -> MemoryClient | None |
None |
Builds the Cortadel client. Override to inject a stand-in in tests. |
on_error |
(exc) -> None | None |
None |
Callback receiving every Cortadel failure. Falls back to a warning on the cortadel_pydantic_ai logger. |
raise_on_error |
bool |
False |
Let Cortadel failures propagate into the run instead of degrading. |
toolset_id |
str |
"cortadel-memory" |
Id of the contributed toolset. |
cortadel_toolset(...) takes the same names where they apply — base_url, user_id, api_key,
app_name, timeout, top_k (default 10), search_mode, rerank, memory_type,
client_factory, on_error, raise_on_error, id — since it has no recall or persistence of
its own.
Per-user scoping
A Cortadel client is bound to one user id at construction — no method takes a user id — so multi-tenant agents pass a callable. The integration keeps one pooled client per resolved id:
from dataclasses import dataclass
from pydantic_ai import Agent
from cortadel_pydantic_ai import CortadelMemory
@dataclass
class Deps:
user_id: str
agent = Agent(
"openai:gpt-5",
deps_type=Deps,
capabilities=[CortadelMemory(user_id=lambda ctx: ctx.deps.user_id)],
)
await agent.run("What do you know about me?", deps=Deps(user_id="e2e-alice"))
await agent.run("What do you know about me?", deps=Deps(user_id="e2e-bob"))
Call await memory.aclose() on shutdown to drain any backgrounded writes and release the pooled
HTTP connections.
Running the tests
The suite is fully offline — no live Cortadel server, no network, no API keys. Cortadel is stubbed
at the client boundary; the pydantic-ai side runs for real against its in-process TestModel and
FunctionModel.
cd integrations/pydantic-ai
uv sync --extra test
uv run pytest -q
Requirements
- Python ≥ 3.10
- pydantic-ai ≥ 2.29.0 (
pydantic-ai-slim>=2.29.0,<3.0.0) — the floor at whichAgent(capabilities=[...])andpydantic_ai.capabilities.AbstractCapabilityexist - cortadel ≥ 1.0.0, < 2.0.0 (the official Python SDK)
- A running Cortadel server — either the hosted service at
https://app.cortadel.ai, or self-hosted:docker compose upfrom the repo root, thenhttp://localhost:3001
Links
- Cortadel: https://cortadel.ai
- Source and issues: https://github.com/cortadel/cortadel
- Pydantic AI capabilities: https://ai.pydantic.dev/capabilities/overview/
Licensed under Apache-2.0.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cortadel_pydantic_ai-0.1.0.tar.gz.
File metadata
- Download URL: cortadel_pydantic_ai-0.1.0.tar.gz
- Upload date:
- Size: 23.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f92d7cd0890d6edaf214cd55fea806c93a8c4b8ecd8973d000106cfd381e4b87
|
|
| MD5 |
7b6c01c5eb35ebb8e7beac52b26cec85
|
|
| BLAKE2b-256 |
b0b7dc23abd59b5995b8df373bd369cf7c04bb52c62a98dbddf73ad1d83be716
|
File details
Details for the file cortadel_pydantic_ai-0.1.0-py3-none-any.whl.
File metadata
- Download URL: cortadel_pydantic_ai-0.1.0-py3-none-any.whl
- Upload date:
- Size: 26.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4746d3ea8e4a902a287ebb312d35138e73bb0d2589464a7041e218786b923076
|
|
| MD5 |
b922f7679fed485e85b44c59c8e61c72
|
|
| BLAKE2b-256 |
d82c2dc0f4c898bf98d142291839fa4b47b17c6837e4dd42447f5b0bb3720789
|