Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Dynamic Tenant Context Resolution

Welcome back. In our previous lesson, we successfully established the "front door" to our agentic system by creating an API endpoint and a middleware that extracts a tenantId from each incoming request. This identifier is our key, and in this lesson, we're going to use it to unlock the door.

Our goal now is to implement the core of our multi-tenant architecture: tenant context resolution. We will take the tenantId and use it to fetch a complete, tenant-specific operational context—including their designated agent configurations, available tools, and custom prompts—from the data stores we designed in earlier modules. This process ensures that every request is handled with the precise resources and permissions allocated to that specific tenant.

By the end of this lesson, you will have transformed a simple tenantId string into a fully configured, context-aware agent, ready for execution.

From Identifier to Context: The Resolution Flow

In a multi-tenant system, "context" is a rich object that dictates the application's behavior for a specific tenant. It goes far beyond a simple ID. Our TenantContext will encapsulate everything the orchestrator needs to process a request:

  • Tenant-specific metadata (e.g., subscription plan, feature flags).
  • A curated list of tool definitions the tenant is permitted to use.
  • A collection of prompt templates tailored to the tenant's brand or workflow.
  • Configuration details for the agents themselves, such as which LLM to use or specific behavioral instructions.

The architectural question is where this resolution should happen. While our middleware is great for quick, synchronous tasks like header validation, performing database lookups there can introduce latency and complexity. A cleaner, more maintainable pattern is to perform the full context resolution within the API route handler itself, or more accurately, within a dedicated service layer that the handler calls.

This approach keeps our middleware lean and focused on routing and authentication, while our API layer orchestrates the business logic of fetching data and preparing the agent for execution.

Architecting for Tenant Data Isolation

To load tenant-specific data, we must first have a way to store it. Recall the database schemas we designed in Modules 3 and 7. The fundamental principle is linking every resource—tools, prompts, agent configurations—back to a tenant via a tenantId foreign key.

Multi-Tenant SaaS Architecture in 3 Simple Steps

The video "Multi-Tenant SaaS Architecture in 3 Simple Steps" provides an excellent conceptual overview of this data model.

First, watch the segment explaining data isolation, which demonstrates how a tenant_id or organization_id is used in database queries to fetch only the relevant data. Then, review the section where an Entity-Relationship Diagram (ERD) is constructed. Pay close attention to how tables like Article and Comment are linked back to the Organization and User tables. This is analogous to how our tools and prompts tables will be linked to our tenants table.

With this database structure, resolving the tenant context becomes a matter of performing a series of targeted queries: "For this tenantId, fetch all associated tools, prompts, and agent settings."

Step 1: Implementing a Tenant-Aware Service

To encapsulate the database logic, we'll create a TenantService. This service will be solely responsible for interacting with our data stores to resolve the full tenant context. This adheres to the single-responsibility principle and makes our code much easier to test and maintain.

How to Build Multi-Tenant APIs in Node.js

This blog post from OneUptime details several production-grade patterns for building multi-tenant services. We'll adapt its concepts for our TenantService.

First, read the section on the Tenant-Aware Repository Pattern. This demonstrates how to create a base class that automatically scopes all database queries to the current tenant. Then, review the section on Tenant-Aware Services, which shows how such a repository is used within a service layer to handle business logic like checking subscription limits.

Drawing inspiration from these patterns, let's define our TenantService. For this implementation, we'll assume you have a Prisma client set up, but the logic is transferable to any ORM or database client.

// src/services/TenantService.ts

import { prisma } from './db'; // Your Prisma client instance

// Define the shape of our fully resolved context
export interface TenantContext {
  tenantId: string;
  name: string;
  plan: 'free' | 'pro' | 'enterprise';
  // We'll load the actual tool functions later. For now, we resolve their definitions.
  tools: { name: string; definition: any }[]; // 'any' for now, ideally your Zod schema
  prompts: { name: string; template: string }[];
  agentConfig: {
    model: string;
    baseInstructions: string;
  };
}

export class TenantService {
  async resolveContext(tenantId: string): Promise<TenantContext | null> {
    const tenant = await prisma.tenant.findUnique({
      where: { id: tenantId },
      include: {
        // Use relation includes to fetch associated resources
        toolMappings: {
          include: {
            tool: true, // Assuming a 'tool' relation on the mapping table
          },
        },
        promptMappings: {
          include: {
            prompt: true, // Assuming a 'prompt' relation
          },
        },
        agentConfiguration: true, // Assuming a one-to-one relation
      },
    });

    if (!tenant) {
      return null;
    }

    // Map the raw database results into our clean TenantContext object
    const context: TenantContext = {
      tenantId: tenant.id,
      name: tenant.name,
      plan: tenant.plan as TenantContext['plan'],
      tools: tenant.toolMappings.map(mapping => ({
        name: mapping.tool.name,
        definition: JSON.parse(mapping.tool.schema), // Assuming schema is stored as JSON string
      })),
      prompts: tenant.promptMappings.map(mapping => ({
        name: mapping.prompt.name,
        template: mapping.prompt.template,
      })),
      agentConfig: {
        model: tenant.agentConfiguration.model,
        baseInstructions: tenant.agentConfiguration.baseInstructions,
      },
    };

    return context;
  }
}

This service provides a single, clean method, resolveContext, that hides the complexity of the underlying database queries.

Step 2: Integrating the Service into the API Route

Now, let's update our API route from the previous lesson to use this new service.

// app/api/chat/route.ts

import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import { TenantService } from '@/services/TenantService';
// We will build this in the next step
import { getTenantAwareAgent } from '@/app/api/chat/agent'; 

export async function POST(request: Request) {
  const headerList = headers();
  const tenantId = headerList.get('x-resolved-tenant-id');

  // Our middleware already guarantees tenantId is present
  if (!tenantId) {
    // This case should ideally not be hit if middleware is configured correctly.
    // It's here for defense-in-depth.
    return new NextResponse('Internal Server Error: Tenant ID not resolved.', { status: 500 });
  }

  const tenantService = new TenantService();
  const tenantContext = await tenantService.resolveContext(tenantId);

  if (!tenantContext) {
    return new NextResponse(JSON.stringify({ message: 'Tenant not found' }), {
      status: 404,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  // Next, we'll use this context to configure and run our agent.
  // For now, let's just return the resolved context to verify it works.
  return NextResponse.json({
    success: true,
    resolvedContext: tenantContext,
  });
}

At this point, you can test your endpoint again. If you provide a valid x-tenant-id that exists in your database, you should receive a JSON response containing the complete, resolved context for that tenant.

Step 3: Dynamically Configuring the Agent with prepareCall

We have our tenantContext object. The final, crucial step is to use it to dynamically configure an agent for the current request. Creating a new agent instance for every request would be inefficient. Instead, we can define a single, generic agent and use the Vercel AI SDK's callOptions feature to specialize it at runtime.

This pattern is powerful because it allows us to maintain a single agent definition while ensuring it only uses the tools and prompts available to the current tenant.

This diagram illustrates our goal. Instead of giving the agent keys to all 30 tools ("All Tools Pattern"), our tenant context resolution allows us to provide only the 5 specific tools the tenant needs ("Unlock Pattern"), enhancing security and efficiency.

The AI SDK's prepareCall function is the perfect mechanism for this.

Agents: Configuring Call Options - AI SDK

The Vercel AI SDK documentation provides a comprehensive guide to using callOptions for dynamic agent configuration.

Read through this document, paying close attention to: The three-step process: callOptionsSchema, prepareCall, and the call itself. The basic example of injecting user context into instructions. The dynamic tool configuration example. This is very close to what we need. The RAG pattern in the Advanced Patterns section, which shows that prepareCall can be asynchronous. Combining multiple modifications, which demonstrates how to dynamically change tools, models, and instructions all at once.

Let's apply this pattern. We'll create a file that exports a function, getTenantAwareAgent, which returns our agent definition.

// app/api/chat/agent.ts

import { ToolLoopAgent, z } from 'ai';
import { openai } from '@ai-sdk/openai';
import { TenantContext } from '@/services/TenantService';
import { createTool } from '@/utils/tool-factory'; // A hypothetical factory to create executable tools

// Define a schema for our call options that matches our TenantContext
const tenantContextSchema = z.custom<TenantContext>();

// Define a single, reusable agent instance
export const tenantAwareAgent = new ToolLoopAgent({
  model: openai('gpt-4-turbo'), // A default model
  callOptionsSchema: tenantContextSchema,
  
  // Base tools that might be available to all tenants. Can be empty.
  tools: {}, 

  // The magic happens here!
  prepareCall: async ({ options: tenantContext, ...settings }) => {
    // Dynamically generate executable tools from the definitions in the context
    // This is a crucial step. You need a mechanism to map the stored schema
    // to an actual, executable function.
    const availableTools = {};
    for (const toolDef of tenantContext.tools) {
        availableTools[toolDef.name] = createTool(toolDef.name, toolDef.definition);
    }
    
    // Find a specific prompt from the context, e.g., 'main_system_prompt'
    const systemPromptTemplate = tenantContext.prompts.find(p => p.name === 'main_system_prompt')?.template;

    // Construct the final system prompt by combining the base instructions
    // from the agent config with the resolved template.
    const finalInstructions = `${tenantContext.agentConfig.baseInstructions}\n\n${systemPromptTemplate || ''}`;

    return {
      ...settings,
      // Override settings based on the resolved tenant context
      model: openai(tenantContext.agentConfig.model), // Use the tenant-specific model
      tools: availableTools, // Provide only the tools this tenant can access
      instructions: finalInstructions, // Provide the customized system prompt
    };
  },
});

Finally, we update our API route one last time to use this agent and pass the tenantContext into its options.

// app/api/chat/route.ts (Final Version)

import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import { TenantService } from '@/services/TenantService';
import { tenantAwareAgent } from '@/app/api/chat/agent'; 
import { createAgentUIStreamResponse } from 'ai';

export async function POST(request: Request) {
  const { messages } = await request.json();

  const headerList = headers();
  const tenantId = headerList.get('x-resolved-tenant-id')!;

  const tenantService = new TenantService();
  const tenantContext = await tenantService.resolveContext(tenantId);

  if (!tenantContext) {
    return new NextResponse(JSON.stringify({ message: 'Tenant not found' }), {
      status: 404,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  // Pass the resolved context to the agent via `options`
  return createAgentUIStreamResponse({
    agent: tenantAwareAgent,
    messages,
    options: tenantContext, // This is now type-safe thanks to `callOptionsSchema`
  });
}

With this final piece in place, your API endpoint now performs a complete, secure, and dynamic resolution of the tenant context on every request, ensuring the agent operates precisely within the bounds defined for each tenant.

Conclusion

In this lesson, we bridged the gap between identification and execution. You've implemented a robust pattern for resolving a full tenant context from a simple identifier and used it to dynamically configure an AI agent at runtime.

Key Takeaways:

  • Tenant-Aware Services: Encapsulate database logic for tenant context resolution in a dedicated service class to keep your API handlers clean.
  • Data-Driven Configuration: The behavior of your agentic system should be driven by data stored in your tenant, tool, and prompt stores, not by hardcoded logic.
  • The Power of prepareCall: The Vercel AI SDK's prepareCall function is the ideal mechanism for dynamically specializing a generic agent with tenant-specific tools, prompts, and configurations on a per-request basis.
  • Secure by Design: This architecture ensures that one tenant can never access the tools or prompts of another, providing strong data and operational isolation.

You have now created a fully context-aware agent. In the next lesson, we will feed this agent into the orchestrator we designed in Module 5. The orchestrator will then use this correctly-configured agent to begin decomposing tasks and executing the agentic workflow.

Can't find a good explanation? Sign up and we'll make it for you

Sign up