Migrating to Agno v3.0

Guide to migrate your Agno applications from v2 to v3.

If you have questions during your migration, we can help! See Get Help for more information.

Refer the v3.0 Changelog for the full list of changes.

Want to migrate automatically? Jump to Migrate with AI for a prompt you can paste into Claude, Cursor or any coding agent.

Installing Agno v3

If you are already using Agno, you can upgrade to v3 by running:

pip install -U agno

Migrating your Agno DB

The built-in migration makes two schema changes:

  1. Session runs move to their own table. In v2, every session row held its full run history as a single JSON blob in the runs column. In v3, each run is its own row in a dedicated runs table (agno_runs by default), which removes the write amplification and unbounded row growth of the blob design.
  2. On the SQL adapters, a user_id column (with index) is added to the evals, components, knowledge, schedules, schedule-runs and metrics tables, for user isolation. The metrics unique key changes from (date, aggregation_period) to include user_id. Document and KV backends need no schema change here: per-user scoping on those comes from the v3 write path, so on them the migration only moves the runs.

Take a restorable backup and stop application writers before applying migrations. The v3 learning re-key also runs when applicable; it uses a non-transactional read/copy/delete sequence and must finish before the upgraded application accepts writes.

Run the migration with the same database configuration as your application:

migrate_to_v3.py
import asyncio

from agno.db.postgres import PostgresDb  # or SqliteDb, MongoDb, RedisDb, ...
from agno.db.migrations.manager import MigrationManager

db = PostgresDb(db_url="postgresql+psycopg://...")

# Step 1: run all v3 migrations (runs table + user_id columns)
asyncio.run(MigrationManager(db).up())

# Step 2: inspect a sample. This does not prove the migration is complete.
print(db.get_runs(limit=5))

Before migrating, take a restorable database backup. Before any cleanup, compare all relevant legacy session/run identities and their history with the new runs storage, including sessions outside the sample. An empty database may legitimately have no runs. A nonempty sample can contain unrelated new runs and is not proof that every legacy run was copied.

Cleanup is a separate, optional operator action after that complete verification. SQL adapters expose cleanup_legacy_runs_column; document and key-value adapters expose cleanup_legacy_runs_field. Passing force=True bypasses their legacy-data check and permanently removes that backup history. Keep the legacy data until you have verified completeness and can restore the external backup.

On async adapters (AsyncPostgresDb, AsyncMySQLDb, AsyncSqliteDb, and AsyncMongoDb), await get_runs and any separately approved cleanup operation.

Vector databases are migrated separately. If you use per-user knowledge with a vector table created before v3, run the matching script from libs/agno/migrations/v2_to_v3 (migrate_sql_vectordbs.py, migrate_field_vectordbs.py or migrate_sentinel_vectordbs.py, depending on your vector store) to add user_id scoping to existing collections. On the schema-based stores (PgVector, SingleStore, LanceDB, Milvus, ClickHouse, Redis, Cassandra, Couchbase) an un-migrated table raises a ValueError on user-scoped searches instead of returning empty results. Schemaless stores (Qdrant, Pinecone, Upstash, Chroma, MongoDB, OpenSearch, SurrealDB) need no migration: pre-v3 documents stay visible to every user as shared.

Notes:

  • The migration is non-destructive and idempotent: the legacy runs column is preserved as a backup, and re-running the migration never duplicates runs.
  • Reads keep working before, during and after the migration. Sessions merge the runs table with any legacy blob, so an un-migrated session still shows its history.
  • cleanup_legacy_runs_column() refuses to run while legacy data is present unless you pass force=True. Only consider force=True after a restorable backup and complete history verification. Cleanup permanently deletes the blob, which is the only copy of your history if the migration did not actually copy it.
  • Supported everywhere sessions are stored: Postgres, MySQL, SQLite, SingleStore, MongoDB, Redis, Valkey, Firestore, DynamoDB, SurrealDB, JSON, and GCS JSON, plus the async Postgres, MySQL, SQLite and MongoDB adapters.

For the full storage design and per-adapter details, see the v3 storage migration guide in the repository.

Migrating your Agno code

Each section covers one breaking change, with before and after examples.

1. Sessions and runs (denormalization)

Reading sessions is unchanged. session.runs is still populated, now from the runs table:

v3_sessions.py
session = agent.get_session(session_id="s1")
session.runs  # still works, loaded from the runs table

# New: fetch runs directly, without loading the whole session
runs = db.get_runs(session_id="s1")
run = db.get_run(run_id="...")

If you queried the runs column of the sessions table directly (SQL, dashboards, exports), point those queries at the runs table instead. After cleanup the column no longer exists:

SELECT run_id, run_data FROM agno_runs WHERE session_id = 's1' ORDER BY run_index;

2. Workflow HITL: flat kwargs → HumanReview

Workflow primitives no longer accept flat HITL kwargs. All human-in-the-loop configuration lives in one HumanReview object.

This is how it looked in v2:

v2_hitl.py
from agno.workflow.step import Step

step = Step(
    name="deploy",
    executor=deploy,
    requires_confirmation=True,
    confirmation_message="Deploy to production?",
)

This is how it looks in v3:

v3_hitl.py
from agno.workflow.step import Step
from agno.workflow.types import HumanReview

step = Step(
    name="deploy",
    executor=deploy,
    human_review=HumanReview(
        requires_confirmation=True,
        confirmation_message="Deploy to production?",
    ),
)

Field mapping: every flat kwarg keeps its name inside HumanReview, except hitl_max_retriesmax_retries and hitl_timeouttimeout. This applies to Step, Steps, Loop, Condition and Router.

3. Removed and renamed parameters

These deprecated parameters have been removed. Update them to their v3 names:

Agent and Team constructors:

v2 (removed)v3
enable_user_memoriesupdate_memory_on_run
search_session_historysearch_past_sessions
num_history_sessionsnum_past_sessions_to_search
num_past_session_runsnum_past_session_runs_in_search
v3_agent_params.py
agent = Agent(
    update_memory_on_run=True,
    search_past_sessions=True,
    num_past_sessions_to_search=3,
)

continue_run / acontinue_run: the updated_tools parameter is removed. Pass requirements (a list of RunRequirement, available on the paused run output) instead of a modified ToolExecution list:

v3_continue_run.py
run = agent.run("...")  # pauses for confirmation
for requirement in run.requirements:
    requirement.confirm()
agent.continue_run(run_id=run.run_id, requirements=run.requirements)

JWT middleware and authorization_config: secret_key is removed. Use verification_keys, which takes a list:

v3_jwt.py
JWTMiddleware(verification_keys=["your-key"])  # was: secret_key="your-key"

MCPToolbox: auth_tokens and auth_headers are removed. Use auth_token_getters (same shape: a mapping of auth source names to token callables).

4. Reasoning requires an explicit model

The reasoning=True shortcut has been removed. Pass a native reasoning model explicitly:

v2_reasoning.py
agent = Agent(model=OpenAIResponses(id="gpt-5.5"), reasoning=True)
v3_reasoning.py
agent = Agent(
    model=OpenAIResponses(id="gpt-5.5"),
    reasoning_model=OpenAIResponses(id="o4-mini"),
)

5. The Workflow constructor is keyword-only

Workflow no longer accepts positional arguments:

v2_workflow.py
workflow = Workflow("my-workflow", steps=[...])
v3_workflow.py
workflow = Workflow(id="my-workflow", steps=[...])

Team is unchanged: Team([agent_1, agent_2]) still works. The keyword form Team(members=[...]) is preferred for clarity but is not required.

6. User isolation: user_id across the platform

With user_isolation enabled on AgentOS, data is now scoped per user across memories, knowledge, evals, metrics, schedules and vector databases, in addition to sessions. What this means for your code and data:

  • user_id columns were added to the schedules, schedule-runs and evals tables; the built-in migration handles this.
  • Metrics aggregate per user: the unique key changed from (date, aggregation_period) to (user_id, date, aggregation_period). Deployments without isolation see the same single-row-per-date shape as before; sessions without a user_id aggregate into a shared bucket.
  • Vector database collections created before v3 have no per-user scoping. On schema-based stores, searching them with a user_id raises a ValueError telling you to run the vector database migration — an un-migrated table fails loudly instead of silently returning empty results. On schemaless stores (Qdrant, Pinecone, Upstash, Chroma, MongoDB, OpenSearch, SurrealDB) pre-v3 documents are simply treated as shared.

7. Background execution and durable queues

Background runs are now capped at 32 per replica. In v2 each background=True submission spawned an unbounded asyncio.create_task; in v3 runs beyond the cap wait as PENDING instead of overloading the process. Raise or disable the cap with AgentOS(queue=QueueConfig(max_concurrency=...)) or AGNO_BACKGROUND_MAX_CONCURRENCY.

The rest of the queue is opt-in through QueueConfig:

  • durable=True: accepted requests become committed rows that survive crashes, restarts and deploys; any replica's worker can execute them. Enables Idempotency-Key deduplication and the /queue operations endpoints.
  • redis=...: one setting wires both the live event stream and cross-replica cancellation, so runs can be resumed and cancelled from any replica. Redis is coordination, never truth. A Redis fault degrades the live view; it cannot lose or corrupt a run.

External framework agents (LangGraph, Claude, etc.) stream inline when background=true is requested, so their runs are not resumable. See AgentOS Background Execution.

8. Culture feature removed

The experimental culture feature (enable_agentic_culture, add_culture_to_context, CulturalKnowledge, the agno_culture table) has been removed. Remove any references; if you need shared knowledge across users, use Knowledge instead.

9. Entity memory is isolated per user

If you use EntityMemoryStore with namespace="user", your existing rows are shared across users and must be re-keyed.

In v2 the row key carried no user component, so two users who recorded an entity with the same name and type wrote to the same physical row: one user's facts overwrote the other's and then appeared in their prompt context. In v3 the key embeds a digest of the user_id. Global and custom namespaces are unchanged.

Stop application writes while re-keying and keep them stopped until the report has been reviewed. This applies whether you call the learning helper directly or reach it through MigrationManager or the AgentOS migration endpoints.

Pre-v3 rows are re-keyed by the migration, not at runtime — until you run it, reads still match the old shared rows. The re-key is part of the v3.0.0 migration, so MigrationManager(db).up() (or POST /databases/all/migrate) covers it along with everything else:

v3_rekey_entities.py
from agno.learn.migrations import rekey_user_entity_learnings

# Only needed if you are not running the full v3.0.0 migration.
# dry_run=True is the default: it reports what would change without writing.
print(rekey_user_entity_learnings(db))

# Apply it once the dry run looks right
result = rekey_user_entity_learnings(db, dry_run=False)
print(result["rekeyed"], result["merged"], result["failed"])

This migration cannot be reversed. down() refuses the re-key, because the pre-v3 key is shared across users and restoring it would collide the rows again. Back up the learnings table before running it.

Reading the report: rekeyed moved to the owner's key, and keyed was already correct. merged is expected rather than an error — if the upgraded application wrote to the user-scoped key before the migration ran, the entity exists in two rows and they are folded together, with the newer row winning. conflicts and failed need an operator: resolve them, then re-run the helper.

Legacy-keyed rows whose stored content records a different user than their owner column held two users' data before the fix and cannot be separated. The migration moves these to the quarantined_user namespace instead of deleting them: the content is preserved and entity memory stops reading it. They remain listed and mutable through the /learnings API for whichever user the owner column names. To delete them instead — along with every row that has no owner — and let entity memory re-capture from conversation, pass purge_unrecoverable=True.

Already user-keyed mixed rows are reported as contaminated_keyed and remain in place, even with purge_unrecoverable=True. Inspect contaminated_keyed, malformed, and unowned alongside conflicts and failed before resuming writes. MongoDB may have overwritten the owner evidence during a historic collision, so zero detected contamination does not prove that no collision occurred. The helper cannot automatically separate every mixed-user record.

Two API changes come with it:

  • delete / adelete take a keyword-only user_id and refuse namespace="user" deletes without it. Previously any caller could delete another user's entity by name.
  • get / aget require a user_id in that namespace instead of returning an arbitrary user's row.

10. Smaller changes

  • AgentOS metadata routes: GET /models was removed (its data moved into GET /config under available_models), and GET / is now a minimal landing response. GET /info is the single unauthenticated metadata endpoint.
  • Toolkits have an id, used by AgentOS to reference tools stably.
  • Schedule provenance columns: the schedules table gains eight nullable columns (managed_by, target_type, target_id, created_by_run_id, created_by_session_id, updated_by_run_id, updated_by_session_id, disabled_reason), added by the v3.0.0 migration on SQLite and PostgreSQL. Existing rows keep NULL provenance and no data is rewritten, so this needs no action beyond running the migration. If you query the schedules table directly with SELECT *, expect the extra columns.
  • update_schedule is restricted to a column allow-list: it now writes only name, description, method, endpoint, payload, cron_expr, timezone, timeout_seconds, max_retries, retry_delay_seconds, enabled, next_run_at and disabled_reason. Passing a provenance column raises a ValueError instead of silently repointing the row's owner or target. user_id is not an update field either: it scopes the update to that owner, so an update passing the wrong user_id matches nothing.
  • Removed toolkit methods: DuckDuckGoTools.duckduckgo_search -> web_search and duckduckgo_news -> search_news; FileTools.check_escape -> Toolkit._check_path; PgVector.enable_prefix_matching removed (dead helper); BrightDataTools.get_screenshot no longer takes output_path.
  • Removed learn aliases: MemoriesConfig -> UserMemoryConfig, MemoriesStore -> UserMemoryStore, Decision -> DecisionLog.
  • Eval result files: store_result_in_file's eval_id parameter is now run_id, and {eval_id} is no longer accepted in file_path_to_save_results templates -- use {run_id}. POST /eval-runs returns the id the row was stored under.
  • Workspace refuses credential files by default: env files and conventional credential paths (*.pem, .ssh, .aws, credentials.json, *.tfvars, ...) are excluded, so an agent that reads one starts getting a refusal. Re-allow specific paths with Workspace(".", allow_paths=["config/credentials.json"]). Committed templates such as .env.example become readable.
  • Studio memory forms: enable_agentic_memory and memory_manager_id are gone from the Studio create/edit forms. Use learning_name (a registry machine) or enable_learning=True. The Agent/Team constructor parameters are unchanged, so stored configs keep rehydrating.
  • SQLite attempts WAL mode: SqliteDb/AsyncSqliteDb enable WAL when available, which can create -wal and -shm sidecar files. For a live database, use SQLite's backup API or another consistent snapshot method. Ordinary copies of active database and sidecar files can capture inconsistent state.
  • MultiMCPTools removed: use one MCPTools per server. The allow_partial_failure parameter is gone with it.
  • Knowledge insert API: add_content -> insert(), add_content_async -> ainsert(), add_contents_async -> ainsert_many().
  • Flat Google tool modules removed: import from agno.tools.google.* instead of agno.tools.gmail, agno.tools.googlesheets, agno.tools.googlecalendar, agno.tools.google_maps, agno.tools.google_drive, agno.tools.google_bigquery. Their parameters changed too: creds_path -> credentials_path, auth_port -> oauth_port.
  • Other toolkit renames: SeltzTools.max_documents -> max_results (older seltz SDKs still work through a fallback; seltz>=1.2.0 is needed for the scope, domain and date filters); BrandfetchTools drops async_tools; StudioTool -> StudioTools; GDriveContextProvider -> GoogleDriveContextProvider.
  • AgentOS MCP config: AgentOS(enable_mcp_server=..., mcp_config=...) -> mcp= (a bool or MCPConfig). The earlier mcp_server= and MCPServerConfig spellings remain deprecated compatibility aliases.
  • Removed model APIs: the agno.models.metrics module and its Metrics alias are gone -- use agno.metrics / RunMetrics. Model.classify_error -> ModelProviderError.classify(error).
  • LanceDb.use_tantivy is removed; passing it now raises a TypeError.
  • Pagination is validated: page without limit, or page < 1, now raises a ValueError instead of being ignored.
  • Schedule names are unique per user: the unique key becomes (user_id, name). If duplicate names already exist, the v3.0.0 migration aborts rather than stamping itself done -- resolve the duplicates and re-run.
  • Mistral requires mistralai>=2.0.0: the v1 compatibility layer is gone. Upgrade with pip install -U "agno[mistral]".
  • Cerebras default model: Cerebras and CerebrasOpenAI now default to gpt-oss-120b instead of llama-4-scout-17b-16e-instruct. Pin the old id explicitly if you depend on it.
  • agno[postgres] installs a working driver: the extra previously installed psycopg-binary only, so PostgresDb failed with ModuleNotFoundError: No module named 'sqlalchemy'. It now pulls psycopg and sqlalchemy; you can drop any manual pins you added to work around it.

Migrate with a Coding Agent

Paste the prompt below into Claude, Cursor, or any coding agent with access to your repository. It applies the mechanical changes and flags everything that needs your judgment.

Copy this prompt
You are migrating a codebase from Agno v2 to Agno v3. Apply the following
changes carefully. Make the mechanical edits directly; for anything marked
JUDGMENT, report it to me instead of guessing.

## 1. Renamed parameters (mechanical)

Rename these constructor parameters wherever Agent(...) or Team(...) is called:
- enable_user_memories        -> update_memory_on_run
- search_session_history      -> search_past_sessions
- num_history_sessions       -> num_past_sessions_to_search
- num_past_session_runs       -> num_past_session_runs_in_search

Rename these too, wherever they appear:
- JWTMiddleware / authorization_config: secret_key="k" -> verification_keys=["k"]
  (note the list wrapping)
- MCPToolbox: auth_tokens= or auth_headers= -> auth_token_getters= (same value)

Rename these imports and modules wherever they appear:
- from agno.tools.gmail / googlesheets / googlecalendar / google_maps /
  google_drive / google_bigquery  -> from agno.tools.google.<module>
- Google toolkit kwargs: creds_path= -> credentials_path=, auth_port= -> oauth_port=
- MultiMCPTools(...)            -> one MCPTools per server (JUDGMENT: report it)
- Knowledge.add_content(        -> .insert(
- Knowledge.add_content_async(  -> .ainsert(
- Knowledge.add_contents_async( -> .ainsert_many(
- StudioTool                    -> StudioTools
- GDriveContextProvider         -> GoogleDriveContextProvider
- from agno.models.metrics import Metrics -> from agno.metrics import RunMetrics
- SeltzTools kwarg max_documents= -> max_results=
- BrandfetchTools: drop any async_tools= argument
- LanceDb: drop any use_tantivy= argument
- AgentOS(enable_mcp_server=X, mcp_config=Y) -> AgentOS(mcp=Y or X)

Rename these methods and imports wherever they appear:
- DuckDuckGoTools: .duckduckgo_search(  -> .web_search(
- DuckDuckGoTools: .duckduckgo_news(    -> .search_news(
- FileTools: .check_escape(<path>)      -> ._check_path(<path>, self.base_dir)
  (the v3 helper takes the base dir explicitly; a bare token rename breaks the call.
  Do NOT rename LocalFileSystemTools.check_escape - that one still exists)
- BrightDataTools.get_screenshot(...): drop any output_path= argument
- PgVector: remove any .enable_prefix_matching(...) call (the helper method is gone)
- from agno.learn import MemoriesConfig  -> UserMemoryConfig
- from agno.learn import MemoriesStore   -> UserMemoryStore
- from agno.learn import Decision        -> DecisionLog

## 1b. continue_run updated_tools (JUDGMENT)

Agent/Team continue_run and acontinue_run no longer accept updated_tools
(List[ToolExecution]). The v3 path is requirements=<list of RunRequirement from
the paused run output>. This is a structural change to HITL continue code, not
a rename: find every call site passing updated_tools and report it.

## 2. Workflow HITL config (mechanical)

Step, Steps, Loop, Condition and Router no longer accept flat HITL kwargs.
Collect any of these kwargs from their constructors:
  requires_confirmation, confirmation_message, on_reject, requires_user_input,
  user_input_message, user_input_schema, allow_multiple_selections,
  requires_output_review, output_review_message, requires_iteration_review,
  iteration_review_message, on_error, hitl_max_retries, hitl_timeout, on_timeout
and move them into a single human_review=HumanReview(...) argument
(import: from agno.workflow.types import HumanReview).
Rename while moving: hitl_max_retries -> max_retries, hitl_timeout -> timeout.
All other names are unchanged inside HumanReview.

## 3. Reasoning (JUDGMENT)

Agent(reasoning=True) no longer exists. Comment the argument out with a
`# TODO(agno-v3):` marker so the file stays importable, and report every
occurrence: the fix is to set reasoning_model=<a native reasoning model
instance>, and I need to choose which model.

## 4. Keyword-only Workflow constructor (mechanical)

The Workflow constructor is keyword-only. Convert positional arguments:
  Workflow("wf-id", ...) -> Workflow(id="wf-id", ...)
  (v2's first positional argument was `id`, NOT `name` - converting it to name=
  would silently re-identify the workflow: new auto-generated id, different
  AgentOS routing and database rows)
Team is NOT keyword-only: leave Team([a, b]) alone.

## 4b. Entity memory user isolation (JUDGMENT)

If the code constructs EntityMemoryStore(...) with namespace="user", report it.
The row key changed in v3 and existing rows must be re-keyed by the v3.0.0
migration; the change is not reversible, so I need to confirm it. Also report
any call to that store's delete/adelete or get/aget: they now require a
keyword-only user_id in the "user" namespace.

## 5. Culture feature (JUDGMENT)

The culture feature was removed. Find any use of: enable_agentic_culture,
add_culture_to_context, CulturalKnowledge, update_cultural_knowledge, or
imports from agno.culture. Comment constructor arguments out with a
`# TODO(agno-v3):` marker so files stay importable; leave other usages in
place. Report every occurrence.

## 6. Direct SQL against sessions (JUDGMENT)

Search for SQL, dashboard queries or exports reading the `runs` column of the
agno_sessions table. In v3 runs live in the agno_runs table
(run_id, session_id, run_type, run_index, run_data, ...). Report every hit.

## 7. AgentOS API consumers (JUDGMENT)

If this codebase calls the AgentOS HTTP API: GET /models was removed (use
GET /config -> available_models), and GET / returns a minimal landing payload.
Report any client code using those routes.

## 8. Database migration (do NOT automate the destructive step)

Write (but do not execute) a migration script for me with exactly this shape:

    import asyncio
    from agno.db.migrations.manager import MigrationManager
    # build db exactly as the app does
    asyncio.run(MigrationManager(db).up())
    runs = db.get_runs(limit=5)
    print(runs)  # sample inspection only; an empty database may have no runs
    print("Keep legacy data. Verify all legacy session/run identities and "
          "history against the new storage and retain a restorable backup.")

Never call cleanup_legacy_runs_column / cleanup_legacy_runs_field yourself,
and never pass force=True on my behalf: cleanup permanently deletes the legacy
run history. A nonempty sample or a UI spot check does not prove every legacy
run was copied. Report a complete comparison plan and backup/restore procedure
for my review before any separate cleanup decision.

## Output

When done: list every file you changed with a one-line summary, then a
JUDGMENT section listing every finding from steps 1b, 3, 4b, 5, 6 and 7 that needs my
decision. If the repo pins agno in requirements/pyproject, update it to >=3.0.

The prompt deliberately refuses to run the destructive cleanup step. Keep it that way: compare the complete migrated session and run history with the legacy records and retain a restorable backup before reclaiming legacy storage.