Recommended resources
The freely available resources this lesson will direct you to.
Introduction
In our previous lesson, we designed the database schemas and data access patterns necessary to create and enforce tenant-specific resource availability. We established how to map agents, tools, and prompts to tenants and explored robust methods like decorator patterns and enhanced database clients to automate security. However, this infrastructure is currently static; it lacks the mechanism to connect a live, incoming request to a specific tenant's configuration.
This lesson focuses on activating that connection. We will address how to resolve a tenant's identity from an incoming API request and, crucially, how to inject this context throughout the request's lifecycle so it's available to our agentic pipeline. You'll learn the standard Node.js pattern for managing request-scoped context, AsyncLocalStorage, to avoid the pitfalls of passing identifiers through every function. We will then connect this to the Vercel AI SDK's capabilities, preparing the ground for dynamically loading tenant-specific resources.
The Challenge: Context Propagation
Imagine a typical API request flow. A request arrives at a controller, which calls a service, which in turn calls a repository or another utility. If the tenantId is needed deep within this call stack, the naive approach is to pass it as a parameter to every single function along the way.
controller(req) -> service(req.tenantId) -> repository(req.tenantId) -> ...
As a seasoned developer, you recognize this "prop drilling" on the backend is fragile, verbose, and error-prone. It clutters function signatures and creates a maintenance headache. Let's start by watching a brief explanation of this exact problem.
Apparently Node.js has its own version of LocalStorage
The video "Apparently Node.js has its own version of LocalStorage" from Software Developer Diaries clearly articulates the problem of passing repetitive context, like a tenantId, through complex function chains.
Watch the segment from the beginning. The presenter demonstrates how function signatures grow unwieldy as more contextual parameters (userId, timestamp, tenantId) are added, leading to unreadable code. This sets the stage for the solution we will implement.
The Solution: AsyncLocalStorage for Request-Scoped Context
The modern solution to this problem in Node.js is async_hooks.AsyncLocalStorage. It allows you to create a storage context that is unique to the current asynchronous execution chain—in our case, a single HTTP request. This means you can set the context once, at the beginning of the request, and retrieve it anywhere downstream without passing it explicitly.
The "Context Engineering" article you've seen before formalizes this initial step as Ingest.
Context Engineering for Commercial Agent Systems - Jeremy Daly
This article by Jeremy Daly outlines the critical steps of a production-grade context engine. The first step, "Ingest," is precisely what we are implementing now.
Read the section Step 1: Ingest. Pay close attention to the concept of an IdentityEnvelope which bundles together tenant_id, user_id, roles, etc., at the very start of a run. This is the conceptual model for the context we will create and store.
To implement this in Node.js, we will create a middleware that performs this ingestion step. First, let's define the shape of our context and instantiate the storage. The article "How to Build Multi-Tenant APIs in Node.js" provides a great practical starting point.
How to Build Multi-Tenant APIs in Node.js
This article from OneUptime gives a concrete implementation of multi-tenancy in Node.js using AsyncLocalStorage.
Focus on the two code blocks in the section Tenant Context. This provides a clear example of a TenantContext interface and a set of helper functions (getTenantContext, getTenantId, etc.) built around an AsyncLocalStorage instance. This is a clean, reusable pattern.
Following this pattern, we can create our own context management file:
src/context/tenantContext.ts
import { AsyncLocalStorage } from 'node:async_hooks';
import type { Tenant } from '@prisma/client'; // Assuming you have a Tenant type from Prisma
export interface TenantContext {
tenant: Tenant;
requestId: string;
userId?: string; // Optional user context
}
// Create a singleton instance of AsyncLocalStorage
export const tenantStorage = new AsyncLocalStorage<TenantContext>();
// Helper to get the full context
export function getTenantContext(): TenantContext | undefined {
return tenantStorage.getStore();
}
// Helper to get just the tenant ID, with a failsafe
export function requireTenantId(): string {
const context = tenantStorage.getStore();
if (!context?.tenant?.id) {
throw new Error('Tenant context is required but not available or is missing an ID.');
}
return context.tenant.id;
}
Implementing the Tenant Identification Middleware
With our storage mechanism in place, we can now write an Express middleware to identify the tenant from the request, fetch the tenant's data from the database, and establish the context for all downstream handlers.
Common strategies for identifying a tenant include:
- HTTP Header:
X-Tenant-ID: <tenant_uuid> - Subdomain:
tenant-slug.yourapp.com - URL Path:
/api/tenants/<tenant_uuid>/... - JWT Claim: A
tenantIdfield within the user's authentication token.
The article from OneUptime provides excellent, ready-to-use examples for these strategies.
How to Build Multi-Tenant APIs in Node.js
This resource provides several middleware implementations for different tenant identification strategies.
First, read the code under the middleware class. Focus on the fromHeader() method. This is a common and straightforward strategy. Note how it finds the tenant and then calls setTenantContext, which wraps the next() call inside tenantStorage.run(context, ...). Then, review the API Routes section to see how this middleware is applied to specific routes in an Express application.
Let's adapt the fromHeader strategy for our application. This middleware would live in a file like src/middleware/tenant.ts.
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { prisma } from '../lib/db'; // Your Prisma client instance
import { tenantStorage, TenantContext } from '../context/tenantContext';
export async function tenantResolutionMiddleware(req: Request, res: Response, next: NextFunction) {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ message: 'X-Tenant-ID header is missing.' });
}
try {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
});
if (!tenant) {
return res.status(404).json({ message: 'Tenant not found.' });
}
const context: TenantContext = {
tenant,
requestId: uuidv4(),
};
// Run the rest of the request chain within the async context
tenantStorage.run(context, () => {
next();
});
} catch (error) {
console.error("Tenant resolution failed:", error);
next(error);
}
}
This middleware would then be applied in your main application file (app.ts or server.ts) to all tenant-scoped routes.
The following sequence diagram provides a perfect visual summary of the entire flow we've just built.

Injecting Context into the Vercel AI SDK Agent
Now for the final piece: injecting this resolved context into the agent. Since our agent logic will be called from within the tenantStorage.run(...) scope, we can retrieve the tenant context at any time. The Vercel AI SDK provides an elegant, type-safe mechanism for passing such dynamic, request-specific data to an agent: dynamic call options.
This feature allows you to define a schema for data you expect to pass in when you invoke the agent. The agent can then use this data within its execution loop, for instance by modifying its system prompt or selecting different tools.
The video "Build an Agent with Vercel" explains this powerful feature of the AI SDK.
Watch the segment on Dynamic Call Options. The presenter explains how you can define a schema for dynamic inputs (like userId and accountType) and then use a prepareCall function to alter the agent's behavior based on that data. This is the perfect pattern for injecting our TenantContext.
Here is how you would combine these concepts in an API route handler:
src/app/api/chat/route.ts
import { createAgent } from '...'; // Your agent factory
import { getTenantContext } from '@/context/tenantContext';
import { z } from 'zod';
// 1. Define the agent with call options for tenant context
const agent = createAgent({
// ... other agent config
callOptions: z.object({
tenantId: z.string(),
tenantPlan: z.enum(['free', 'pro', 'enterprise']), // Example from tenant data
}),
prepareCall: ({ callOptions }) => {
// This function runs at the start of every agent invocation.
// Here, you can dynamically modify the agent's behavior.
return {
// Example: Add tenant info to the system prompt
system: `You are an assistant for a user on the ${callOptions.tenantPlan} plan. Tenant ID: ${callOptions.tenantId}.`,
// We will explore tool filtering in the next lesson
};
},
});
// 2. In your API route handler (wrapped by the middleware)
export async function POST(req: Request) {
// Your middleware has already run, so the context is available
const tenantContext = getTenantContext();
if (!tenantContext) {
return new Response('Tenant context not found.', { status: 500 });
}
const { messages } = await req.json();
const result = await agent(
{
messages,
},
{ // 3. Pass the resolved context into the agent call
callOptions: {
tenantId: tenantContext.tenant.id,
tenantPlan: tenantContext.tenant.plan, // Assuming 'plan' is a field on your Tenant model
},
}
);
return result.readableStreamResponse;
}
Note: The POST function above would not be directly exported. It would be the handler passed to a framework like Express or Next.js that has already applied the tenantResolutionMiddleware.
Conclusion
In this lesson, you have implemented the critical link between an incoming request and your multi-tenant architecture. You have learned how to resolve a tenant's identity and, using AsyncLocalStorage, make that context securely and cleanly available throughout the entire request lifecycle. Finally, you saw how to inject this context directly into a Vercel AI SDK agent using dynamic call options, setting the stage for true tenant-aware agent behavior.
Key Takeaways:
- Ingest First: Tenant identity resolution should happen at the entry point of your application, typically in middleware.
- Avoid Prop Drilling:
AsyncLocalStorageis the standard pattern in Node.js for propagating request-scoped context without polluting function signatures. - Middleware is Key: An Express or Next.js middleware is the perfect place to house your tenant identification logic and initialize the async context.
- Inject via Call Options: The Vercel AI SDK's
dynamic call optionsprovide a type-safe and explicit way to pass resolved tenant context to your agent, enabling it to dynamically adapt its behavior.
We have successfully resolved the tenant context and injected it into the agent's entry point. In our next lesson, we will leverage this context to perform the final step: dynamically filtering the agents, tools, and prompts available to the agent based on the active tenant's specific entitlements.