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:
Validate API key
Check that OPENROUTER_API_KEY is configured. Fail fast if not.
Authenticate session
Verify the user has a valid portal session. Extract organizationId and userId.
Resolve Employee
Look up the WorkerDefinition by employeeId from the URL. Reject if not found or status === "coming_soon".
Check for approvals
If the request body contains approvals[], execute them directly — bypass the loop entirely.
Parse request body
Extract message, chatHistory, and stream flag.
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
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
// 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
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 Type | Body Shape | Response |
|---|---|---|
| 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[] } |