Cortadel × CrewAI
cortadel-crewai makes Cortadel the memory a CrewAI crew runs on. Hand one CortadelMemory to
Crew(memory=...) and every agent recalls from your Cortadel server before a task and writes back
to it afterwards, so the crew keeps what it learned between kickoffs instead of starting from zero.
Retrieval (hybrid BM25 + vector fused with RRF), embedding, deduplication, entity extraction and
bi-temporal versioning all happen server-side — nothing is embedded locally, and no local vector
store is created. Two smaller seams ship alongside it: search_memory / add_memories as native
CrewAI tools for agents that should decide when to remember, and an event-bus listener that hands
each finished task to Cortadel’s extraction pipeline.
Install
Section titled “Install”pip install cortadel-crewaiQuickstart
Section titled “Quickstart”import os
from crewai import Agent, Crew, Task
from cortadel_crewai import CortadelMemory
# A Cortadel client is bound to ONE user id at construction — there is no per-call# user parameter, so build one CortadelMemory per user you serve.memory = CortadelMemory( base_url=os.environ.get("CORTADEL_BASE_URL", "http://localhost:3001"), user_id="e2e-crewai-demo", # api_key="...", # omit when the server runs with auth disabled rerank="cross_encoder", # let Cortadel rerank with its cross-encoder dedupe_window=50, # don't re-inject facts recalled a moment ago)
engineer = Agent( role="Platform Engineer", goal="Answer infrastructure questions using what the team already decided", backstory="You have a long memory and never make the team relitigate a settled question.",)
task = Task( description="Which cluster does the billing service deploy to, and what did we decide " "about its database? If you do not know yet, say so plainly.", expected_output="A short recommendation, citing the earlier decision if one exists.", agent=engineer,)
# Seed a decision so the next run has something to find.memory.remember( "The billing service deploys to the eu-west staging cluster, " "backed by the shared Postgres instance rather than its own database.")
crew = Crew(agents=[engineer], tasks=[task], memory=memory)print(crew.kickoff())
memory.close()Run it twice: the first kickoff has nothing to recall, the second answers from what the first one
learned, because the memories outlived the process. The agents themselves still need their own LLM
key (OPENAI_API_KEY or whatever CrewAI is configured with) — Cortadel does its embedding and
extraction server-side, so that key is for the agents, not for memory.
What you get
Section titled “What you get”Three independent seams. Use any combination.
1. CortadelMemory — automatic memory. A real subclass of crewai.Memory, accepted anywhere
CrewAI takes a memory instance: Crew(memory=...), Agent(memory=...), or a scoped view via
memory.scope("/agent/researcher"). Every turn recalls before the task and stores after it, without
the agent cooperating or even knowing. Because it is a genuine Memory, CrewAI’s own built-in
Search memory / Save to memory tools (crewai.tools.memory_tools.create_memory_tools) route to
Cortadel too.
2. cortadel_tools() — agent-invoked memory. Two crewai.tools.BaseTool subclasses, named
search_memory and add_memories, sharing one client:
from crewai import Agent
from cortadel_crewai import cortadel_tools
support = Agent( role="Support Engineer", goal="Resolve issues without asking the customer to repeat themselves", backstory="You look things up before you ask.", tools=cortadel_tools(base_url="http://localhost:3001", user_id="e2e-crewai-demo"),)search_memory takes query and top_k and returns the hits as a formatted list, with each hit’s
categories in front of it. add_memories takes messages (a list of self-contained statements),
stores each one, and reports back how many were saved and how many Cortadel recognised as duplicates
— a 2xx does not mean a new memory was written, so the tool reads the SKIP_DUPLICATE event rather
than counting requests. Both work with or without CortadelMemory.
3. CortadelConversationListener — turn-level capture. An event-bus listener that writes each
completed task to Cortadel as a two-turn conversation (task description as the user message, task
output as the assistant message) through add_conversation, so Cortadel’s own extraction pipeline
distils the durable facts without the agent deciding anything and without a CrewAI-side LLM hop:
from cortadel_crewai import CortadelConversationListener
listener = CortadelConversationListener( user_id="e2e-crewai-demo", session_id="billing-service-rollout", project="platform",)crew.kickoff() # each finished task is persisted via add_conversationConstructing it is enough — BaseEventListener.__init__ subscribes it to the global bus. Keep the
reference for as long as you want it active: a garbage-collected listener takes its client with it.
Configuration
Section titled “Configuration”CortadelMemory accepts every crewai.Memory field (read_only, root_scope,
default_importance, …) plus:
| Option | Type | Default | Meaning |
|---|---|---|---|
base_url |
str |
$CORTADEL_BASE_URL, else http://localhost:3001 |
Cortadel server origin |
user_id |
str | None |
$CORTADEL_USER_ID |
User that owns the memories. Required — construction raises ValueError without one |
api_key |
str | None |
$CORTADEL_API_KEY |
Bearer token; omit when the server runs with auth disabled |
app_name |
str |
"cortadel-crewai" |
App name recorded for access logging, and sent as AddOptions.app on writes |
client |
SyncCortadelClient | None |
None |
Inject a pre-built client instead of constructing one |
search_mode |
str |
"hybrid" |
hybrid, text, or vector |
rerank |
str | None |
None |
Set to "cross_encoder" to rerank with Cortadel’s cross-encoder |
memory_type |
str | None |
None |
Pin on writes and filter reads: episodic, semantic, procedural |
infer |
bool |
True |
False stores text verbatim and skips Cortadel’s entity/category extraction |
raise_on_error |
bool |
False |
True propagates Cortadel errors instead of degrading to empty results |
on_error |
Callable[[BaseException], None] | None |
None |
Callback handed the exception on every Cortadel failure |
dedupe_window |
int |
0 |
When > 0, suppress the last N memory ids already returned by recall; 0 disables it |
api_key and on_error are declared exclude=True, repr=False, so neither the token nor a
non-serialisable callable reaches repr(memory) or model_dump() — which matters because
Crew.copy() dumps every agent.
cortadel_tools() takes base_url / user_id / api_key / app_name / client /
raise_on_error / on_error, and builds both tools around one shared client.
CortadelConversationListener takes those seven plus:
| Option | Type | Default | Meaning |
|---|---|---|---|
session_id |
str | None |
None |
Groups every fact extracted during this run |
project |
str | None |
None |
Project scope, e.g. a repository name |
tags |
list[str] | None |
None |
Tags applied to every extracted fact |
is_agent_memory |
bool |
False |
True extracts facts about the assistant — what a self-improving crew wants |
min_output_chars |
int |
1 |
Skip tasks whose output is shorter than this |
The search_memory tool’s top_k defaults to 10, matching the SDK’s own SearchOptions; its
mode and rerank mirror search_mode / rerank above. Every retrieval count — the tool’s
top_k and the limit CrewAI passes into recall — is clamped into Cortadel’s accepted 1–50 range
before it goes out.
CrewAI re-recalls on every turn (the task description before a task, then the whole message history
at kickoff), so the same facts can land in the prompt repeatedly. dedupe_window=N suppresses the
last N memory ids already returned — a window of ids, not of recalls, so dedupe_window=50 with
ten hits per recall spans roughly the last five. It is off by default, because suppression is wrong
when an agent legitimately asks the same question twice.
How it works
Section titled “How it works”The extension point is crewai.memory.unified_memory.Memory — the single memory class CrewAI
1.10.0 introduced when it collapsed the old short-term/long-term/entity/external split.
CortadelMemory subclasses it and overrides the text-level API that every consumer in the
framework actually calls:
| CrewAI call | Cortadel call |
|---|---|
recall(query, limit=N) — Agent before a task, LiteAgent, Flow, the built-in Search memory tool |
search(query, SearchOptions(top_k=N, mode, rerank, memory_type)) |
remember(content, ...) / remember_many([...]) — the built-in Save to memory tool and any explicit save |
add(text, AddOptions(app, metadata, infer, memory_type)) |
remember_conversation(messages) — added by this package |
add_conversation(messages, ConversationOptions(...)) |
list_records(limit, offset) |
list(ListOptions(page, size, memory_type)) |
forget(record_ids=[...]) |
delete(ids) |
CrewAI supplies the retrieval count itself rather than the package choosing one: recall(query, limit=5) before a task, limit=20 for the kickoff message history and for its built-in
Search memory tool, limit=10 in LiteAgent.
Overriding the text API is the deliberate choice, and it is what the page below the README is for.
One level down sits crewai.memory.storage.backend.StorageBackend, the runtime_checkable
protocol CrewAI’s own LanceDB backend implements — and its search() takes a
query_embedding: list[float]. Cortadel’s API takes a text query and embeds it itself with its own
model, so that seam cannot be served faithfully; serving it would mean embedding locally with a
different model than the corpus was written with. So this package plugs in one level up, and fills
the storage slot with InertStorage, a structurally-compatible no-op. That slot cannot simply be
left empty: Memory’s storage field defaults to the string "lancedb", and
Memory.model_post_init builds a real LanceDB store whenever it finds a str there. Handing it a
non-str object short-circuits that branch, so importing this package never pulls in LanceDB, never
writes ./.crewai into your working directory, and never needs an embedder or an OPENAI_API_KEY
for memory. The inherited scope helpers that are deliberately not overridden (list_scopes, info,
tree, list_categories) still call through to it, and get “nothing here” instead of an
AttributeError.
The other two seams are the framework’s own as well. The tools are ordinary
crewai.tools.BaseTool subclasses with pydantic args_schema models, so they drop into
Agent(tools=[...]) beside anything else. The listener is a crewai.events.BaseEventListener whose
setup_listeners() registers an @crewai_event_bus.on(TaskCompletedEvent) handler — CrewAI’s
documented way to observe a run.
Underneath, everything goes through cortadel.SyncCortadelClient, the blocking client with one
background event loop for its lifetime, because CrewAI calls memory synchronously from agent and task
threads. arecall() delegates to the sync override.
Known limits
Section titled “Known limits”- Recall keeps CrewAI’s
limit, not the repo-widetop_k.CortadelMemory.recalloverridescrewai.Memory.recall, and the framework calls it by keyword in four places, so renaming the parameter would break the integration outright. It maps straight ontoSearchOptions.top_k. Thesearch_memorytool does usetop_k. - No
await_persistand noscope_recall_to_session. Every write here is blocking, so there is no detached write to wait for. The SDK’sSearchOptions.session_idis never set, so recall always searches everything Cortadel knows about the user; the listener’ssession_idgroups writes only. - Cortadel is a flat per-user namespace. CrewAI’s scope hierarchy (
/crew/research/...) and its category taxonomy are sent along asAddOptions.metadatafor provenance, but they are not retrieval filters —recall(scope=..., categories=...)does not narrow the search. Cortadel does its own organisation (entity graph, server-inferred categories). depth="deep"is accepted and ignored. CrewAI’s deep recall drives a local LLM flow over a vector store; Cortadel already fuses BM25 and vector arms with RRF server-side and will cross-encode on request. Running both would pay for a second, weaker retrieval brain.update(),reset()andreset_all()are inert. Cortadel is bi-temporal: a correction is a new write that supersedes the older version server-side, and there is no bulk-reset endpoint. Editing or wiping in place would destroy that history. To delete, pass explicit ids toforget(record_ids=[...]).forget()withoutrecord_idsdeletes nothing and returns0, rather than guessing at criteria the server cannot express. With ids it returnslen(record_ids), not a server-side count — Cortadel’sdeleteanswers with a confirmation string, so deleting two ids of which one never existed still reports2.list_records(offset=...)is approximate. Cortadel paginates by page/size with no row offset, so the offset is floored to a page boundary:list_records(limit=50, offset=25)returns records 0–49. Pass an offset that is a multiple oflimitfor an exact window. Page size is capped at 100.- CrewAI’s own auto-save runs a CrewAI-side LLM first. This package does not override
extract_memories(), so the framework’s automatic post-task save (base_agent_executor→memory.extract_memories(raw)→memory.remember_many(...)) distils the task text with its LLM (Memory.llm, defaultgpt-5.4-mini) before Cortadel sees it. With no LLM key configured that step raises, CrewAI logs “Failed to save to memory”, and the auto-save silently no-ops — recall still works, but nothing is written. To write without a CrewAI-side LLM, useCortadelConversationListener,cortadel_tools(), or an explicitmemory.remember(...). Agent(memory=...)does not surviveCrew.copy().BaseAgent.copy()re-validates the agent through amodel_dump(), and CrewAI’s discriminated union turns aCortadelMemoryback into a plaincrewai.Memory.Crew.copy()copies every agent this way, andkickoff_for_each(),train()andtest()all use it. Crew-level memory is unaffected —Crew.copy()reattaches the live instance — so with those three entry points rely onCrew(memory=...). A plainkickoff()keeps both.- A degraded
remember()returns a placeholder, notNone. When the server call fails andraise_on_erroris False you get an unpersisted record (record.id == "",record.metadata["cortadel_persisted"] is False), because CrewAI’sSave to memorytool readsrecord.scope/record.importancestraight off that return value with noNonecheck.remember_many()still returns only what was actually stored, so a fully degraded batch is[]. - A re-raised listener error lands on the bus, not in your task. Its handler runs on CrewAI’s
event-bus thread pool, so with
raise_on_error=Truethe error surfaces on the busFuture, not in the task that produced the output. - Metadata does not round-trip. The Cortadel Python SDK always returns
NoneforMemoryListItem.metadata/MemoryDetail.metadata, so the CrewAI bookkeeping written on save cannot be read back through this SDK. - One user per instance. A Cortadel client is bound to one user id at construction; there is no
per-call user parameter. Serve several users by building one
CortadelMemoryeach. - Tested against one CrewAI release. The test suite is fully offline (real CrewAI, stubbed
Cortadel client, no server and no keys) and has only been run against
crewai1.15.15. The>= 1.10.0floor is derived from published wheel contents, not from a test run on 1.10.0.
Requirements
Section titled “Requirements”- Python ≥ 3.11, < 3.14. The upper bound mirrors crewai’s own range; the lower bound is one
version above it because 3.10 cannot actually be installed — crewai pulls chromadb, whose
Python-3.10 branch resolves to
onnxruntime1.24.3, and that release ships no cp310 wheel and no sdist to build from. crewai≥ 1.10.0 — the release that introduced the unifiedcrewai.Memorysurface.crewai.memory.unified_memoryandcrewai.memory.typesdo not exist in 1.0.0–1.9.3, so this package cannot import there. 1.10.0 is also the release that removed the olderExternalMemory+crewai.memory.storage.interface.Storageseam; that seam is not supported here.cortadel≥ 1.0, < 2.0 — the Python SDK, pulled in automatically.- A running Cortadel server: the hosted service at
https://app.cortadel.ai, or your own (docker compose up→http://localhost:3001, see Self-hosting). Mint the API key it needs with Authentication.
cortadel-crewaion PyPI- Source — including two runnable examples, one per seam
- All integrations