AI Business Maturity Model
Certifications
Find a CoachFind a SpeakerSign In
Deep Dive

/

Part 5

Part 5 of 6

The API Layer

How a Message Gets In

The route handler is the entry point for every user message. It validates the request, authenticates the user, resolves the correct Employee, and wires the streaming engine to the HTTP response. Security is enforced here — the AI can never impersonate another organization.

The Request Flow

Every user message follows this path through the API layer before the engine starts:

1

Validate API key

Check that OPENROUTER_API_KEY is configured. Fail fast if not.

2

Authenticate session

Verify the user has a valid portal session. Extract organizationId and userId.

3

Resolve Employee

Look up the WorkerDefinition by employeeId from the URL. Reject if not found or status === "coming_soon".

4

Check for approvals

If the request body contains approvals[], execute them directly — bypass the loop entirely.

5

Parse request body

Extract message, chatHistory, and stream flag.

6

Stream or run

If stream: true, return a ReadableStream (SSE). If stream: false, run to completion and return JSON.

The Route Handler

In Next.js App Router, each Employee gets a dynamic route at app/api/workers/[employeeId]/route.ts. The [employeeId] segment maps to the Worker Definition's id field.

app/api/workers/[employeeId]/route.ts

TypeScript
import { auth } from '@/lib/auth';
import { getWorkerById } from '@/lib/workers/registry';
import { streamWorker, runWorker } from '@/lib/workers/engine';
import { executeApprovedWorkerTools } from '@/lib/workers/approvals';

// Route handler (Next.js App Router: export this as the named HTTP method)
async function handleWorkerRequest(
  request: Request,
  { params }: { params: Promise<{ employeeId: string }> }
) {
  // Step 1: Validate API key
  if (!process.env.OPENROUTER_API_KEY) {
    return Response.json({ error: 'AI service not configured' }, { status: 503 });
  }

  // Step 2: Authenticate — get session from cookie, not request body
  const session = await auth();
  if (!session?.user?.organizationId) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // organizationId and userId come from the SESSION — never from the request body
  // This means the AI can never impersonate another organization
  const { organizationId, id: userId } = session.user;

  // Step 3: Resolve the Employee
  const { employeeId } = await params;
  const worker = getWorkerById(employeeId);

  if (!worker) {
    return Response.json({ error: 'Employee not found' }, { status: 404 });
  }
  if (worker.status === 'coming_soon') {
    return Response.json({ error: 'Employee not yet available' }, { status: 403 });
  }

  // Step 4: Parse request body
  const body = await request.json();
  const { message, chatHistory = [], stream = true, approvals } = body;

  // Step 5: Handle approval re-entry (bypass the loop)
  if (approvals?.length > 0) {
    const results = await executeApprovedWorkerTools(
      worker,
      approvals,
      organizationId,
      userId
    );
    return Response.json({ results });
  }

  // Step 6: Validate message
  if (!message || typeof message !== 'string') {
    return Response.json({ error: 'Message is required' }, { status: 400 });
  }

  // Step 7: Stream or run
  if (stream) {
    const readable = new ReadableStream({
      async start(controller) {
        const encoder = new TextEncoder();
        try {
          for await (const event of streamWorker(worker, message, chatHistory, organizationId, userId)) {
            controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
          }
        } catch (error) {
          const errEvent = { type: 'error', data: { message: 'Processing error' } };
          controller.enqueue(encoder.encode(`data: ${JSON.stringify(errEvent)}\n\n`));
        } finally {
          controller.close();
        }
      }
    });

    return new Response(readable, {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
      }
    });
  }

  // Non-streaming fallback
  const result = await runWorker(worker, message, chatHistory, organizationId, userId);
  return Response.json({ result });
}

Security: Session Scoping

Every tool receives the organizationId as an injected context parameter. Tools never trust params for data scoping — they always use the injected context.

Organization scoping in tools

TypeScript
// WRONG — trusting user-supplied params for scoping
async function searchRecords(params: { organizationId: string; ... }) {
  return db.records.findMany({ where: { organizationId: params.organizationId } });
  // ^ A malicious user could pass any organizationId
}

// CORRECT — always use injected context
async function searchRecords(
  params: { region?: string; category?: string },
  context: { organizationId: string; userId: string }  // Injected from session
) {
  return db.records.findMany({
    where: {
      organizationId: context.organizationId,  // Always from session
      ...(params.region && { region: params.region }),
    }
  });
}

The Worker Registry

The registry is a simple map from Employee ID to WorkerDefinition. Adding a new Employee means registering it here — one line.

lib/workers/registry.ts

TypeScript
import { recordAnalystWorker } from './employees/record-analyst';
import { documentReviewWorker } from './employees/document-review';
import { schedulingAssistantWorker } from './employees/scheduling-assistant';
// ... import all employees

const WORKER_REGISTRY = new Map<string, WorkerDefinition>([
  [recordAnalystWorker.id, recordAnalystWorker],
  [documentReviewWorker.id, documentReviewWorker],
  [schedulingAssistantWorker.id, schedulingAssistantWorker],
  // ... register all employees
]);

export function getWorkerById(id: string): WorkerDefinition | undefined {
  return WORKER_REGISTRY.get(id);
}

export function getAllWorkers(): WorkerDefinition[] {
  return Array.from(WORKER_REGISTRY.values());
}

export function getActiveWorkers(): WorkerDefinition[] {
  return getAllWorkers().filter(w => w.status === 'active');
}

Request and Response Shapes

The API accepts two request shapes and returns two response shapes depending on the stream flag and whether approvals are present.

Request TypeBody ShapeResponse
New message (streaming){ message, chatHistory, stream: true }text/event-stream — SSE events
New message (sync){ message, chatHistory, stream: false }JSON: { result: { type, message } }
Approval re-entry{ approvals: [id1, id2] }JSON: { results: ToolResult[] }

Part 4: Streaming LayerPart 6: Complete Flow