User and session models for multi-tenant AI agents
Your agent is multi-tenant. Your schema is not.
You built the agent as a prototype. One user, one conversation, a messages table with a content column, and life is good. Then a second team wants to use it, then a third, and by the fourth you are pulling conversations for "the wrong user" and shipping hotfixes to filter by email. You invent a "tenant_id" column in a panic and backfill it with educated guesses.
This is how almost every agent project ends up with a leaky multi-tenant model. The fix is not hard, but it has to happen before the second user shows up. Once you have production conversations tied to the wrong rows, you are stuck.
This post is the minimum viable schema for multi-tenant agent services: the 3 tables you need, the tenant guard that prevents cross-tenant reads at the query level, and the session model that lets one user keep multiple agent conversations alive at once.
Why can't you just add a user_id column and call it done?
Because a multi-tenant agent has 3 levels of scope, not one: the tenant (the org or team), the user inside the tenant, and the session inside the user. A conversation belongs to a session, a session belongs to a user, a user belongs to a tenant. Skip any level and you get one of 3 failure modes.
Skip tenant and you cannot separate orgs. Company A's conversations land in the same queries as company B's. You invent a hacky filter and eventually leak.
Skip user and you cannot tell who inside a tenant said what. Compliance asks "who ran this agent" and you cannot answer.
Skip session and every conversation is one long thread. A user who wants to work on 3 projects in parallel has to use 3 browser tabs and you cannot tell the contexts apart.
graph TD
Tenant[Tenant org, team, workspace] --> User1[User alice]
Tenant --> User2[User bob]
User1 --> S1[Session: refactor auth]
User1 --> S2[Session: debug tests]
User2 --> S3[Session: review PR]
S1 --> M1[Messages]
S2 --> M2[Messages]
S3 --> M3[Messages]
style Tenant fill:#dbeafe,stroke:#1e40af
style User1 fill:#dcfce7,stroke:#15803d
style User2 fill:#dcfce7,stroke:#15803d
3 levels, 3 foreign keys, and every query through the data layer gets a tenant guard. That is the pattern.
What are the 3 tables you actually need?
tenants, users, sessions. Messages and tool calls hang off sessions. Every table except tenants has a tenant_id column. This denormalization is intentional and it is what makes the tenant guard fast.
# filename: models.py
# description: Multi-tenant user and session schema using SQLModel.
# Every non-tenant table carries tenant_id for fast guard queries.
from datetime import datetime
from typing import Optional
from sqlmodel import SQLModel, Field, Relationship
class Tenant(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
created_at: datetime = Field(default_factory=datetime.utcnow)
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
tenant_id: int = Field(foreign_key='tenant.id', index=True)
email: str = Field(unique=True, index=True)
role: str = Field(default='member')
created_at: datetime = Field(default_factory=datetime.utcnow)
sessions: list['Session'] = Relationship(back_populates='user')
class Session(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
tenant_id: int = Field(foreign_key='tenant.id', index=True)
user_id: int = Field(foreign_key='user.id', index=True)
title: str
status: str = Field(default='active')
created_at: datetime = Field(default_factory=datetime.utcnow)
last_activity: datetime = Field(default_factory=datetime.utcnow)
user: User = Relationship(back_populates='sessions')
The tenant_id on Session looks redundant (you can reach it through user.tenant_id), but it is not. Denormalizing it means every query that filters by tenant can do so with a direct index hit instead of a join. On an agent service with millions of sessions, this is the difference between a 5-millisecond query and a 500-millisecond one.
How do you enforce the tenant guard on every query?
Wrap every data access function so it takes a tenant_id and filters on it automatically. The goal is to make it impossible to write a query that forgets the guard. If it is impossible, you cannot leak.
# filename: data_layer.py
# description: Every data function takes tenant_id and filters on it.
# No function returns data that ignores tenant scope.
from sqlmodel import Session as DbSession, select
from models import Session, User
def list_user_sessions(db: DbSession, tenant_id: int, user_id: int) -> list[Session]:
stmt = (
select(Session)
.where(Session.tenant_id == tenant_id)
.where(Session.user_id == user_id)
.order_by(Session.last_activity.desc())
)
return list(db.exec(stmt))
def get_session_or_404(db: DbSession, tenant_id: int, session_id: int) -> Session:
stmt = (
select(Session)
.where(Session.tenant_id == tenant_id)
.where(Session.id == session_id)
)
session = db.exec(stmt).first()
if not session:
raise ValueError(f'session {session_id} not found in tenant {tenant_id}')
return session
Read this carefully. The guard is not "if user is admin, skip." It is "every query has tenant_id in the where clause, always." You cannot fetch a row without knowing which tenant you are fetching for. This removes an entire class of bugs before they can happen.
The anti-pattern to avoid: a utility function like get_session(session_id) that does not take tenant_id. Somebody will call it from a route, forget to check tenant ownership afterward, and leak. Delete those functions from your codebase and replace them with the guarded versions.
How do you get tenant_id into every request?
Through the auth layer. The session cookie or JWT contains the user ID. The auth dependency looks up the user, pulls their tenant ID, and attaches both to the request context. Every route handler takes the context as a parameter.
# filename: auth.py
# description: FastAPI dependency that resolves the authenticated user
# and their tenant_id, then injects both into every route.
from fastapi import Depends, HTTPException, Request
from sqlmodel import Session as DbSession
from models import User
class AuthContext:
def __init__(self, user: User, tenant_id: int):
self.user = user
self.tenant_id = tenant_id
async def get_auth(
request: Request,
db: DbSession = Depends(get_db),
) -> AuthContext:
user_id = request.state.user_id # set by auth middleware
user = db.get(User, user_id)
if not user:
raise HTTPException(401, 'unknown user')
return AuthContext(user=user, tenant_id=user.tenant_id)
Every route takes auth: AuthContext = Depends(get_auth) and passes auth.tenant_id into the data layer. The tenant ID comes from the database, not from a client-supplied header, so a malicious client cannot forge it.
For the full auth setup including JWT issuance and refresh rotation, see the JWT Authentication for Agentic APIs post. That one picks up where this post leaves off.
How do you separate sessions for one user?
A Session row per conversation, with status transitions for active/archived/deleted. Users can have multiple active sessions at once, each with its own message history.
# filename: session_ops.py
# description: Create, list, and archive sessions for a user.
# Messages hang off session_id, not user_id.
def create_session(db: DbSession, tenant_id: int, user_id: int, title: str) -> Session:
session = Session(tenant_id=tenant_id, user_id=user_id, title=title)
db.add(session)
db.commit()
db.refresh(session)
return session
def archive_session(db: DbSession, tenant_id: int, session_id: int) -> None:
session = get_session_or_404(db, tenant_id, session_id)
session.status = 'archived'
db.add(session)
db.commit()
The session ID goes into the URL: POST /sessions/{session_id}/messages. The agent loop reads messages by session ID (with the tenant guard). This is what lets one user have "refactor auth" and "debug tests" running in parallel tabs without the contexts bleeding together.
For the LangGraph-backed version of session persistence (with thread IDs and checkpointers), see the LangGraph Persistence: Why Production Agents Need Thread Models post. That one covers the checkpoint layer specifically.
How do you handle roles and permissions inside a tenant?
Keep it small. 3 roles is enough for 99 percent of agent services: owner, admin, member. Owners can delete the tenant. Admins can invite users and see any session in the tenant. Members can only see their own sessions.
Model the role as a column on User, not a separate table. A single enum column is enough until you have real enterprise RBAC requirements, at which point you have probably outgrown this post anyway.
The permission check happens in the data layer, alongside the tenant guard:
# filename: permissions.py
# description: Admins see all tenant sessions; members see only their own.
# Role check lives in the data layer, not scattered across routes.
def list_visible_sessions(
db: DbSession, auth: AuthContext,
) -> list[Session]:
stmt = select(Session).where(Session.tenant_id == auth.tenant_id)
if auth.user.role == 'member':
stmt = stmt.where(Session.user_id == auth.user.id)
return list(db.exec(stmt))
3 lines of filter logic. No scattered if admin: branches in every route. No accidental leaks from routes that forgot to check.
What to do Monday morning
- Open your current schema. If there is no
tenantstable, add it. Backfill existing users into a single default tenant so you have a migration path. - Add
tenant_idto every table that holds user-owned data. Denormalize it; do not rely on joining throughusers. Indexing pays for itself immediately. - Refactor every data function to take
tenant_idas the first argument after the DB session. Delete any function that reads user data without a tenant parameter. - Put the auth dependency in front of every route. Every route handler receives
auth: AuthContextand passesauth.tenant_idinto the data layer. - Write a test that tries to fetch a session from tenant A using tenant B's auth context. It should 404. Run that test in CI forever so nobody reintroduces the leak.
The headline: multi-tenant agent schemas are cheap if you add them on day 1 and expensive if you retrofit them after a leak. 3 tables, denormalized tenant_id, guarded data layer. Ship it before the second user shows up.
Frequently asked questions
Why do AI agents need a multi-tenant schema from day 1?
Because retrofitting tenant isolation after a leak is far more expensive than designing it in. Once production conversations are tied to rows without tenant IDs, you cannot cleanly separate them and you will have to backfill based on guesses. A 3-table schema with tenant_id everywhere prevents the problem before it can happen and costs almost nothing on day 1.
What tables do you need for a multi-tenant AI agent?
At minimum: tenants, users, sessions, plus the usual messages and tool_calls hanging off sessions. Every table except tenants has a tenant_id column that is denormalized down the hierarchy so queries can filter on a single index column. Joining through users to reach the tenant works but gets slow at scale.
Should tenant_id be denormalized onto every table?
Yes. Joining through users to reach tenant_id works for small datasets but becomes slow once sessions or messages grow into the millions. Denormalizing tenant_id onto every table lets every query filter by a single indexed column, which keeps read latency flat as the data grows. The storage cost is trivial.
How do you prevent cross-tenant data leaks in queries?
By wrapping every data access function so it requires a tenant_id parameter and filters on it in the where clause. The pattern makes it impossible to call a data function without scoping by tenant, which removes the "oops I forgot" failure mode at the language level. Delete any function that reads tenant data without a tenant parameter.
How should sessions relate to users in an agent schema?
One-to-many. A user can have multiple sessions active at once, one per conversation context. Messages and tool calls hang off session_id, not user_id, so different conversations stay isolated. The session ID goes into the URL, the tenant guard runs at the data layer, and the user can work on several topics in parallel tabs without mixing history.
Key takeaways
- Multi-tenant agent services need 3 levels of scope: tenant, user, session. Collapsing any level leads to cross-tenant leaks or unusable context separation.
- Denormalize
tenant_idonto every non-tenant table. The single-index filter is 100x faster than joining throughusersat scale, and the storage cost is negligible. - Guard the tenant at the data layer, not in route handlers. Every data function takes
tenant_idas the first argument; routes that forget cannot compile. - Resolve
tenant_idfrom the authenticated user inside the DB, not from a client header. A malicious client cannot forge the tenant this way. - One user, many sessions. Messages hang off
session_idso parallel conversations stay cleanly isolated. - To see this schema wired into a full production agent stack with auth, streaming, and observability, walk through the Build Your Own Coding Agent course, or start with the AI Agents Fundamentals primer.
For the OWASP multi-tenant isolation guidance that informed the tenant-guard pattern in this post, see the OWASP Multi-Tenant SaaS Cheat Sheet. The query-level guard is called out there as the most reliable isolation pattern.
Continue Reading
Ready to go deeper?
Go beyond articles. Build production AI systems with hands-on workshops and our intensive AI Bootcamp.