Build a Governed AI Capability Platform
Build validated, observable AI APIs for ticket triage and incident summaries.
Introduction
30 Second Summary
A useful answer for a person can still arrive in a shape that software cannot use reliably. When every team asks for work in its own way, priorities and summaries become inconsistent.
In this project, you will build an internal AI capability platform with stable functions for ticket triage plus incident summarization. The finished platform returns validated JSON contracts through Vercel AI Gateway with attribution metadata for each request.
What You'll Build
Running the demo turns an outage ticket into a fixed triage object beside a clear operational summary of a timestamped incident.
By the end of this project, you'll have:
- A ticket-triage contract that turns support text into six required fields. You can inspect its category, priority, owner, summary, rationale, plus human-review decision.
- An incident-summary contract that transforms an outage timeline into customer impact, current status, highlights, completed actions, next actions, plus unresolved questions.
- A four-case evaluation suite that prints a pass or fail result for realistic operational cases. Printed generation IDs let you trace each call to its provider, usage, cost, duration, authentication, plus routing details in AI Gateway Logs.
- Secret Mission: Add a tenant capability allowlist that blocks unauthorized requests before they reach a model.
What do I need before starting?
You'll need a Mac with Node.js 22+ plus VS Code or Cursor. You'll also need a Vercel account with credits or a payment method for usage-based model requests.
Before We Start
This first checkpoint defines the platform you are about to build. You are creating ticket triage and incident summarization capability contracts so application teams can call validated, governed APIs instead of sending arbitrary prompts.
Set Up the Gateway and Local Project
A capability platform needs a controlled path for every model request. Vercel's AI Gateway provides credentials, spend controls, routing, and request metadata in one place.
Your local project also needs a verified Node.js runtime at version 22+. Pinned dependencies and protected credentials make every later capability run from the same foundation.
In this step, get ready to:
- Prepare an empty VS Code workspace for the project.
- Create a budgeted AI Gateway key with local secret protection.
- Prove the pinned local setup can load every dependency.
Open the project workspace and verify Node.js
A VS Code workspace anchors every project file to one folder. Its integrated terminal starts from that same folder.
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Visual Studio Code into the search bar.
- Press Enter to open VS Code.
VS Code is now ready to create a workspace on your Desktop.
- Select File from the top menu.
- Select Open Folder....
- Select Desktop in the folder dialog.
- Select New Folder.
- Enter ai-capability-platform as the folder name.
- Select Open.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
You should see ai-capability-platform at the top of the Explorer view. This confirms that every file you create next lands in the correct folder.
- Select View from the top menu.
- Select Terminal.
- Check the installed Node.js version by running this command:
node --version
What does this check show?
This check reports the runtime that executes the project scripts. You should see version 22 or higher.
- Check that npm is available by running this command:
npm --version
What does this check show?
npm installs the packages declared by the project. You should see a version number in the terminal.
Runtime check not working?
Confirm that the integrated terminal belongs to the ai-capability-platform workspace. Restart VS Code if either installed command is unavailable in the terminal.
Help me diagnose my Node.js or npm version check.
Create a budgeted gateway key and protect it
The Vercel account holds the credential that authorizes local requests through AI Gateway. The key budget adds a spend guardrail before the project makes any model calls.
Why a managed gateway?
A managed gateway centralizes credentials, budgets, provider routing, and request metadata. Application code can depend on one platform boundary while the gateway manages providers behind it.
This keeps the project focused on capability contracts. Operating a separate proxy would add infrastructure without improving those contracts.
Keep the budget guardrail on
Adding billing details can feel weighty. The supplied examples and evaluations are designed to stay below $0.10.
Set a $1 monthly budget on the key. This is a soft cap, so a request that crosses the limit can complete before later requests are rejected.
- Go to Vercel in your browser.
- Create a Vercel account if you do not already have one.
- Sign in to your Vercel account.
- Add available credits or a payment method if Vercel requests one for paid model usage.
- Open the AI Gateway API Keys page.
The API Keys page is where you create credentials with their own spending limits.
- Select Create key.
- Enter ai-capability-platform-local as the key name.
- Enable a budget in the create-key dialog.
- Set the dollar limit to $1.
- Set the refresh period to monthly.
- Select Create key.
- Copy the key value immediately.
The key value is shown only once, so copying it now protects you from having to create another key. Your budgeted gateway credential is ready.
Treat the copied value as a live credential. Store it only in .env so it stays separate from files that can be shared.
- Select New File... in the Explorer view.
- Enter .env as the file name.
- Press Enter.
- Type AI_GATEWAY_API_KEY= at the start of the first line.
- Paste the copied key after the equals sign.
- Press Cmd+S on macOS or Ctrl+S on Windows to save the file.
- Close the .env editor tab.
You should see .env in the Explorer view. Keep its contents out of screenshots and shared files.
- Select New File... in the Explorer view.
- Enter .env.example as the file name.
- Press Enter.
- Add a safe credential template by pasting this line:
AI_GATEWAY_API_KEY=your-api-key-here
What does this template do?
The example file documents the required environment variable without exposing your live key. Other developers can copy the structure and supply their own credential.
- Save .env.example.
- Confirm that .env.example appears beside .env in the Explorer view.
- Select New File... in the Explorer view.
- Enter .gitignore as the file name.
- Press Enter.
- Protect generated files and local secrets by pasting this content:
node_modules
.env
.DS_Store
What does this file protect?
- The node_modules entry excludes downloaded dependencies.
- The .env entry excludes your live gateway credential.
- The .DS_Store entry excludes a local macOS metadata file.
- Save .gitignore.
- Confirm that .gitignore appears in the Explorer view.
Credential files look wrong?
Keep the real key only in .env. Make sure .env.example contains only the supplied placeholder.
Help me check my environment files without sharing my API key.
Install the pinned project and verify setup
The package manifest turns the empty folder into an ECMAScript module project. Exact package versions keep the local environment aligned with the code used throughout this project.
- Select New File... in the Explorer view.
- Enter package.json as the file name.
- Press Enter.
- Define the project and its pinned dependencies by pasting this content:
{
"name": "ai-capability-platform",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"verify": "node --env-file=.env src/verify.js",
"naive": "node --env-file=.env src/naive-triage.js",
"triage": "node --env-file=.env src/triage-demo.js",
"demo": "node --env-file=.env src/demo.js",
"eval": "node --env-file=.env src/eval.js"
},
"dependencies": {
"ai": "7.0.146",
"zod": "4.6.7"
}
}
What does this manifest configure?
- The type setting makes Node.js interpret project files as ECMAScript modules.
- The engines setting records the required Node.js runtime.
- The scripts object provides stable commands for every project stage.
- The dependencies pin the AI SDK at 7.0.146 and Zod at 4.6.7.
- Save package.json.
- Confirm that package.json appears in the Explorer view.
Package file showing an error?
Confirm that every property in package.json uses double quotes. Check that the final property in each object has no trailing comma.
Help me compare my package manifest with the required project file.
The first dependency install may pause while npm downloads the pinned packages. Terminal activity during this step means the local dependency tree is being assembled.
- Install the dependencies declared in package.json by running:
npm install
What does this command do?
npm reads the dependency versions from package.json. It downloads those packages into the local project.
The terminal should return to its prompt without dependency errors. You should also see node_modules in the Explorer view.
Dependency install failed?
Confirm that the terminal is running inside ai-capability-platform. Check your internet connection if npm cannot download a package.
Help me diagnose the npm dependency installation.
- Select New Folder beside the top-level ai-capability-platform folder.
- Enter src as the folder name.
- Press Enter.
The Explorer view now shows an empty src folder for the project scripts.
- Select the src folder in the Explorer view.
- Select New File....
- Enter verify.js as the file name.
- Press Enter.
- Add the local setup check by pasting this code:
import { generateText } from 'ai';
import { z } from 'zod';
if (!process.env.AI_GATEWAY_API_KEY) {
throw new Error('AI_GATEWAY_API_KEY is missing from .env');
}
console.log('Setup ready');
console.log({
node: process.version,
aiSdkLoaded: typeof generateText === 'function',
zodLoaded: typeof z.object === 'function',
gatewayKeyLoaded: true,
});
What does this setup check prove?
- The imports confirm that the AI SDK and Zod can load from the installed dependency tree.
- The environment check stops immediately when AI_GATEWAY_API_KEY is unavailable.
- The final object reports the Node.js runtime and three successful setup checks.
- Save src/verify.js.
- Confirm that verify.js appears inside src in the Explorer view.
Verification file showing a syntax error?
Confirm that verify.js is inside the src folder. Compare the quotes, braces, and parentheses with the reference below.
Help me find a syntax problem in my setup verification file.
✔️ Awesome, I've got everything!
Your protected environment files, project manifest, and setup check now match the required project structure.
ⓧ I'd like to double check the full code
The local .env file is excluded from this reference because it contains your live credential.
- Compare each saved file with the references below.
AI_GATEWAY_API_KEY=your-api-key-here
node_modules
.env
.DS_Store
{
"name": "ai-capability-platform",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"verify": "node --env-file=.env src/verify.js",
"naive": "node --env-file=.env src/naive-triage.js",
"triage": "node --env-file=.env src/triage-demo.js",
"demo": "node --env-file=.env src/demo.js",
"eval": "node --env-file=.env src/eval.js"
},
"dependencies": {
"ai": "7.0.146",
"zod": "4.6.7"
}
}
import { generateText } from 'ai';
import { z } from 'zod';
if (!process.env.AI_GATEWAY_API_KEY) {
throw new Error('AI_GATEWAY_API_KEY is missing from .env');
}
console.log('Setup ready');
console.log({
node: process.version,
aiSdkLoaded: typeof generateText === 'function',
zodLoaded: typeof z.object === 'function',
gatewayKeyLoaded: true,
});
Before you run the final check, do you expect all four setup signals to succeed?
- Verify the complete local setup by running:
npm run verify
What should you see?
The terminal prints Setup ready. The following object includes your Node.js version.
- The aiSdkLoaded value is true.
- The zodLoaded value is true.
- The gatewayKeyLoaded value is true.
Setup check failed?
If you see AI_GATEWAY_API_KEY is missing from .env, confirm that .env is beside package.json. Check that the environment variable name matches exactly.
If a package cannot load, run the dependency installation again from the ai-capability-platform folder.
Help me diagnose my failed local setup check.
That is the foundation secured. Your runtime can now load the pinned packages and read the protected gateway credential.
Your governed local project is ready. Next, you will send a deliberately free-form request and watch it fail the contract an application needs.
Expose the Free-Form Response Failure
The setup from earlier gives your project a verified runtime plus a protected gateway credential. Now you can test the first capability boundary with Vercel's AI Gateway.
A free-form response can sound useful to a person while still breaking the stable object contract that an application expects. This step makes that gap visible with a live ticket-triage request.
In this step, get ready to:
- Send a support ticket to a live model through AI Gateway.
- Compare the generated paragraph with the required object contract.
- Keep the failing baseline for the structured capability you build next.
Create the naive triage baseline
The AI SDK's generateText() function returns generated content through its text string property when no structured output is configured. This baseline asks for the right information without controlling the response shape.
- Create src/naive-triage.js with the new-file icon beside src in the file sidebar from earlier.
You will see an empty naive-triage.js editor tab inside the existing src folder.
- Add the live ticket-triage request by pasting this code into src/naive-triage.js:
import { generateText } from 'ai';
const { text } = await generateText({
model: 'anthropic/claude-sonnet-5.5',
maxOutputTokens: 200,
prompt: `Write one short paragraph that triages this support ticket. Include a priority, category, and owning team.
Ticket: Checkout requests are returning HTTP 503 across all regions. Customers cannot place orders and there is no workaround.`,
});
console.log('Model response:');
console.log(text);
What does this code do?
- The generateText() call sends the ticket to the model through AI Gateway.
- The absence of a structured output configuration leaves the response in the text string property.
- The anthropic/claude-sonnet-5.5 model receives a limit of 200 output tokens.
- The prompt requests useful triage details while leaving their format open.
- Save src/naive-triage.js.
Before you run the request, predict whether the model returns a JavaScript object or a paragraph of text.
- Generate the first live result by running:
npm run naive
You will see Model response: followed by a plausible triage paragraph. That live paragraph confirms your project can send a model request through the gateway.
Request not returning?
- Check that .env stores the key under AI_GATEWAY_API_KEY.
- Confirm that your Vercel account still has available credits or a payment method.
Help me troubleshoot this live AI Gateway request.
Check the response shape
Application consumers need named fields they can read predictably. The next check tests the response value for the required priority, category, and owner fields.
- Place your cursor at the end of console.log(text) in src/naive-triage.js.
- Press Enter twice to leave one blank line.
- Add the object-contract check by pasting this code below:
const contractPassed =
typeof text === 'object' &&
text !== null &&
'priority' in text &&
'category' in text &&
'owner' in text;
console.log(
contractPassed
? 'CONTRACT CHECK: PASSED'
: 'CONTRACT CHECK: FAILED - the consumer received free-form text, not a stable object.',
);
What does this check prove?
- The typeof text === 'object' condition checks whether the response has an object shape.
- The remaining conditions require the object to expose all three fields that a consumer needs.
- The contractPassed value becomes false when the response is still a string.
- Save src/naive-triage.js.
✔️ Awesome, I've got everything!
Your live request plus its object-contract check are both in place.
ⓧ I'd like to double check the full code
import { generateText } from 'ai';
const { text } = await generateText({
model: 'anthropic/claude-sonnet-5.5',
maxOutputTokens: 200,
prompt: `Write one short paragraph that triages this support ticket. Include a priority, category, and owning team.
Ticket: Checkout requests are returning HTTP 503 across all regions. Customers cannot place orders and there is no workaround.`,
});
console.log('Model response:');
console.log(text);
const contractPassed =
typeof text === 'object' &&
text !== null &&
'priority' in text &&
'category' in text &&
'owner' in text;
console.log(
contractPassed
? 'CONTRACT CHECK: PASSED'
: 'CONTRACT CHECK: FAILED - the consumer received free-form text, not a stable object.',
);
Before you rerun the request, predict whether useful content or the JavaScript value type decides the contract result.
- Test the completed baseline by running:
npm run naive
You will see another plausible triage paragraph. The final line starts with CONTRACT CHECK: FAILED because the consumer received a string instead of an object.
You have now exposed the platform gap that matters: human-readable output does not guarantee a dependable application contract.
Contract check not failing?
- Confirm that the contract-check code remains below console.log(text).
- Compare src/naive-triage.js with the full-code tab for a missing condition or changed message.
Help me debug the unexpected contract result.
Your live baseline proves exactly where free-form generation falls short. Next, you will turn ticket triage into a validated capability with a predictable object contract.
Build the Ticket Triage Contract
The last run proved that Vercel AI Gateway can return a useful answer. A plausible paragraph still breaks the machine-readable contract that an application needs.
This step turns ticket triage into a capability API with a fixed schema. Caller input gets validated before the request reaches the model. The model response returns through a predictable data contract with gateway metadata.
In this step, get ready to:
- Lock in the caller-facing ticket triage request.
- Define validated request and response contracts.
- Run a governed model request with attribution plus routing metadata.
Create the stable capability caller
A stable capability starts with the request that an application developer submits. This demo fixes the caller-facing shape before the execution layer is added.
- In the file sidebar, select the src folder.
- Create a new file named triage-demo.js inside src.
- Paste the caller below into src/triage-demo.js.
import { triageTicket } from './platform.js';
const result = await triageTicket({
tenantId: 'retail-demo',
userId: 'developer-42',
ticket:
'Checkout requests are returning HTTP 503 across all regions. Customers cannot place orders and there is no workaround.',
});
console.log(JSON.stringify(result, null, 2));
What Does This Caller Define?
- The tenantId identifies the application tenant responsible for the request.
- The userId identifies the developer making the request.
- The ticket contains the operational issue that needs triage.
- The final log formats the capability result as readable JSON.
- Save src/triage-demo.js.
- Confirm that triage-demo.js appears inside the src folder in the file sidebar.
Caller File Missing?
Check that the file sits inside src. Confirm that its name ends with .js.
Help me check the location and name of my ticket triage demo file.
Define validated contracts and gateway execution
The caller needs protection on both sides of the model boundary. Zod validates the incoming request plus the six fields returned by the ticket triage capability.
- Select the src folder in the file sidebar.
- Create a new file named platform.js inside src.
- Paste the model configuration plus both schemas into src/platform.js.
import { generateText, Output } from 'ai';
import { z } from 'zod';
const MODEL_ID = 'anthropic/claude-sonnet-5.5';
const triageRequestSchema = z.object({
tenantId: z.string().min(1),
userId: z.string().min(1),
ticket: z.string().min(20),
});
const triageOutputSchema = z.object({
category: z.enum(['availability', 'performance', 'security', 'billing', 'access', 'other']).describe('The single best category for the ticket.'),
priority: z.enum(['P1', 'P2', 'P3', 'P4']).describe('Priority selected using the platform policy in the instructions.'),
owner: z.enum(['sre', 'security', 'billing', 'identity', 'support']).describe('The team that should receive the ticket.'),
summary: z.string().describe('A concise operational summary.'),
rationale: z.string().describe('A brief explanation grounded only in the ticket.'),
requiresHumanReview: z.boolean(),
});
How Do These Contracts Protect Callers?
- The triageRequestSchema rejects missing tenant identifiers.
- The request schema also requires a ticket with at least 20 characters.
- The triageOutputSchema restricts category plus priority plus owner to approved values.
- The output schema requires a summary plus rationale plus human-review decision.
- Save src/platform.js.
- Confirm that platform.js appears beside triage-demo.js in the src folder.
Seeing a Schema Error?
Check every quote plus bracket against the snippet. Pay close attention to the closing characters after triageOutputSchema.
Help me find the syntax problem in my ticket triage schemas.
The schemas define the boundary. The shared execution helper now asks the AI SDK for a validated structured output while AI Gateway handles attribution plus cost-aware routing.
- In src/platform.js, place the cursor after the closing line of triageOutputSchema.
- Append the shared execution helper below.
async function runStructuredCapability({ capability, tenantId, userId, prompt, instructions, schema }) {
const result = await generateText({
model: MODEL_ID,
instructions,
prompt,
maxOutputTokens: 400,
output: Output.object({ schema }),
providerOptions: {
gateway: {
user: userId,
tags: [`tenant:${tenantId}`, `capability:${capability}`, 'env:development'],
sort: 'cost',
},
},
});
const gatewayMetadata = result.providerMetadata?.gateway;
return {
data: result.output,
metadata: {
capability,
generationId: typeof gatewayMetadata?.generationId === 'string' ? gatewayMetadata.generationId : null,
provider: gatewayMetadata?.routing?.finalProvider ?? 'unknown',
costUsd: gatewayMetadata?.cost ?? 'unavailable',
},
};
}
What Does the Execution Helper Do?
- The Output.object configuration makes the generated result conform to the supplied schema.
- The gateway user field attributes the request to the caller.
- The three tags attribute the request to its tenant plus capability plus environment.
- The sort: 'cost' option prefers the lowest-cost healthy provider.
- The metadata envelope exposes the generation ID plus selected provider plus cost.
- Save src/platform.js.
- Confirm that the helper ends with a closing brace after its returned metadata envelope.
Gateway Metadata Underlined?
Check that both imports appear at the top of platform.js. Confirm that Output uses the same capitalization in the import plus helper.
Help me debug the structured capability helper in platform.js.
The helper stays reusable by accepting a schema plus capability-specific instructions. The exported triageTicket function supplies the policy for this first capability.
- Place the cursor after runStructuredCapability in src/platform.js.
- Append the exported ticket triage function below.
export async function triageTicket(request) {
const parsed = triageRequestSchema.parse(request);
return runStructuredCapability({
capability: 'ticket-triage',
tenantId: parsed.tenantId,
userId: parsed.userId,
schema: triageOutputSchema,
instructions: `You are the ticket triage capability for an internal AI platform.
Use this platform policy:
- P1: an active widespread outage or confirmed security compromise.
- P2: major degradation or high risk with material impact.
- P3: limited impact with a workaround or a non-urgent business issue.
- P4: informational or low-impact request.
Choose exactly one category and owner. Base every field only on the supplied ticket. Set requiresHumanReview to true for P1, P2, security, or ambiguous cases.`,
prompt: `Triage this ticket:\n\n${parsed.ticket}`,
});
}
How Does Triage Become a Capability?
- The parse() call validates the caller's request before any model request begins.
- The capability name becomes part of the metadata plus gateway attribution.
- The policy defines the allowed priorities plus the conditions requiring human review.
- The prompt includes only the validated ticket supplied by the caller.
- Save src/platform.js.
- Switch back to src/triage-demo.js from earlier.
The demo imports the same triageTicket symbol that platform.js now exports.
✔️ Awesome, I've got everything!
Your caller plus platform contract are ready. Save both files before running the capability.
ⓧ I'd like to double check the full code
Compare your complete src/platform.js file with this reference.
import { generateText, Output } from 'ai';
import { z } from 'zod';
const MODEL_ID = 'anthropic/claude-sonnet-5.5';
const triageRequestSchema = z.object({
tenantId: z.string().min(1),
userId: z.string().min(1),
ticket: z.string().min(20),
});
const triageOutputSchema = z.object({
category: z.enum(['availability', 'performance', 'security', 'billing', 'access', 'other']).describe('The single best category for the ticket.'),
priority: z.enum(['P1', 'P2', 'P3', 'P4']).describe('Priority selected using the platform policy in the instructions.'),
owner: z.enum(['sre', 'security', 'billing', 'identity', 'support']).describe('The team that should receive the ticket.'),
summary: z.string().describe('A concise operational summary.'),
rationale: z.string().describe('A brief explanation grounded only in the ticket.'),
requiresHumanReview: z.boolean(),
});
async function runStructuredCapability({ capability, tenantId, userId, prompt, instructions, schema }) {
const result = await generateText({
model: MODEL_ID,
instructions,
prompt,
maxOutputTokens: 400,
output: Output.object({ schema }),
providerOptions: {
gateway: {
user: userId,
tags: [`tenant:${tenantId}`, `capability:${capability}`, 'env:development'],
sort: 'cost',
},
},
});
const gatewayMetadata = result.providerMetadata?.gateway;
return {
data: result.output,
metadata: {
capability,
generationId: typeof gatewayMetadata?.generationId === 'string' ? gatewayMetadata.generationId : null,
provider: gatewayMetadata?.routing?.finalProvider ?? 'unknown',
costUsd: gatewayMetadata?.cost ?? 'unavailable',
},
};
}
export async function triageTicket(request) {
const parsed = triageRequestSchema.parse(request);
return runStructuredCapability({
capability: 'ticket-triage',
tenantId: parsed.tenantId,
userId: parsed.userId,
schema: triageOutputSchema,
instructions: `You are the ticket triage capability for an internal AI platform.
Use this platform policy:
- P1: an active widespread outage or confirmed security compromise.
- P2: major degradation or high risk with material impact.
- P3: limited impact with a workaround or a non-urgent business issue.
- P4: informational or low-impact request.
Choose exactly one category and owner. Base every field only on the supplied ticket. Set requiresHumanReview to true for P1, P2, security, or ambiguous cases.`,
prompt: `Triage this ticket:\n\n${parsed.ticket}`,
});
}
This file combines request validation plus structured generation plus gateway metadata in one reusable platform layer.
Compare your complete src/triage-demo.js file with this reference.
import { triageTicket } from './platform.js';
const result = await triageTicket({
tenantId: 'retail-demo',
userId: 'developer-42',
ticket:
'Checkout requests are returning HTTP 503 across all regions. Customers cannot place orders and there is no workaround.',
});
console.log(JSON.stringify(result, null, 2));
This file acts as the consuming application for the ticket triage contract.
Run the ticket triage capability
The caller now reaches a validated execution layer. This check sends one paid model request through the budgeted gateway key.
Before you run it, do you expect the result to look like the free-form paragraph or a fixed object with metadata?
- Switch back to the integrated terminal from earlier.
- Test the structured capability by running this command:
npm run triage
What Should You See?
The terminal prints a top-level data object. It also prints a top-level metadata object.
- The data object contains category plus priority plus owner.
- The same object contains summary plus rationale plus requiresHumanReview.
- The metadata object names the ticket-triage capability.
- The metadata also includes a generation ID plus provider plus cost value.
That is the contract working. The same outage ticket now produces validated fields that another application can consume safely.
Triage Command Failed?
Check that both files are saved inside src. Confirm that the import path in triage-demo.js is ./platform.js.
If the request cannot authenticate, confirm that the protected .env file still supplies AI_GATEWAY_API_KEY.
Help me debug my structured ticket triage run.
Your platform now exposes ticket triage as a validated capability with attributable gateway metadata. Next, you will reuse this execution layer for a second capability with a different contract.
Add Incident Summarization
Your ticket triage capability now returns a validated object through one shared execution layer. You have given application teams one dependable platform contract.
One capability can still be a thin model wrapper. A second capability proves the same layer can support a distinct contract through the same AI Gateway controls.
In this step, get ready to:
- Define the incident summarization contract.
- Export summarizeIncident through the shared execution helper.
- Run a demo that reveals both capability contracts.
Define the incident contracts
An incident timeline has different inputs from a support ticket. Its request contract gives Zod a clear boundary before any timeline reaches the model.
- In src/platform.js, find the closing line of triageRequestSchema.
- Add the incident request contract directly below it by copying this code:
const incidentRequestSchema = z.object({
tenantId: z.string().min(1),
userId: z.string().min(1),
timeline: z.string().min(40),
});
What does this schema protect?
- The tenantId field identifies the application tenant making the request.
- The userId field preserves caller attribution.
- The timeline field requires enough source material for a meaningful incident summary.
- Save src/platform.js.
- Check the existing ticket capability by running this command:
npm run triage
What does this check prove?
This command executes the existing ticket demo through its package script. A successful result proves that src/platform.js still loads after the new request schema was added.
You should see the same six-field triage data object with its metadata envelope. You have kept the original ticket contract intact.
Did the ticket demo stop working?
Check that incidentRequestSchema sits after the closing line of triageRequestSchema.
Make sure every field remains inside the new z.object block.
Help me find the syntax problem in my incident request schema.
The response contract shapes a timeline into operational fields that another application can consume. Bounded arrays keep each list focused on the most important incident facts.
- In src/platform.js, find the closing line of triageOutputSchema.
- Add the incident output contract directly below it by copying this code:
const incidentOutputSchema = z.object({
title: z.string(), executiveSummary: z.string(), customerImpact: z.string(),
status: z.enum(['investigating', 'identified', 'monitoring', 'resolved']),
timelineHighlights: z.array(z.string()).max(5), actionsTaken: z.array(z.string()).max(5),
nextActions: z.array(z.string()).max(5), unresolvedQuestions: z.array(z.string()).max(5),
});
How is the incident output shaped?
- The string fields capture the incident title plus its executive summary and customer impact.
- The status field restricts the current state to four supported values.
- The array fields cap each operational list at five entries.
- Save src/platform.js.
- Check the existing ticket capability again by running this command:
npm run triage
Why rerun the existing capability?
The ticket demo imports the same platform module that now contains both schemas. A successful result confirms that the new output schema is valid JavaScript.
You should see a validated ticket triage object again. The new response contract now coexists with the original contract.
Seeing a schema syntax problem?
Check the commas between every property in incidentOutputSchema.
Confirm that the status values remain inside one z.enum call.
Help me debug my incident output schema.
Export the incident capability
The shared helper already owns structured generation plus gateway attribution. The new function only needs to validate its request before supplying an incident-specific contract and instructions.
- In src/platform.js, scroll below the closing line of triageTicket.
- Add the new exported capability by copying this code:
export async function summarizeIncident(request) {
const parsed = incidentRequestSchema.parse(request);
return runStructuredCapability({ capability: 'incident-summary', tenantId: parsed.tenantId, userId: parsed.userId, schema: incidentOutputSchema, instructions: `You are the incident summarization capability for an internal AI platform.
Summarize only facts present in the timeline. Preserve operationally important times, impact, mitigations, and current state. Do not invent a root cause. Use an empty array for missing list information and say unknown in a string field when the timeline does not provide the fact.`, prompt: `Summarize this incident timeline:\n\n${parsed.timeline}` });
}
What does this capability add?
- The request passes through incidentRequestSchema.parse() before the model boundary.
- The capability name becomes incident-summary inside the shared metadata and gateway tag.
- The model instructions preserve supplied facts without inventing a root cause.
- The output passes through incidentOutputSchema before reaching the caller.
- Save src/platform.js.
- Confirm that the updated platform module still runs by using this command:
npm run triage
What does this run confirm?
The existing demo imports the updated module before calling triageTicket. A successful object confirms that the new export has not broken the shared platform file.
You should see the ticket triage contract and its metadata envelope. The incident function is now available to any local module that imports it.
Does the module fail to load?
Confirm that summarizeIncident appears after the complete triageTicket function.
Check the closing backtick around each multiline string.
Help me debug the summarizeIncident export.
✔️ Awesome, I've got everything!
Great. Double-check that you saved src/platform.js before moving on.
ⓧ I'd like to double check the full code
import { generateText, Output } from 'ai';
import { z } from 'zod';
const MODEL_ID = 'anthropic/claude-sonnet-5.5';
const triageRequestSchema = z.object({
tenantId: z.string().min(1),
userId: z.string().min(1),
ticket: z.string().min(20),
});
const incidentRequestSchema = z.object({
tenantId: z.string().min(1),
userId: z.string().min(1),
timeline: z.string().min(40),
});
const triageOutputSchema = z.object({
category: z.enum(['availability', 'performance', 'security', 'billing', 'access', 'other']).describe('The single best category for the ticket.'),
priority: z.enum(['P1', 'P2', 'P3', 'P4']).describe('Priority selected using the platform policy in the instructions.'),
owner: z.enum(['sre', 'security', 'billing', 'identity', 'support']).describe('The team that should receive the ticket.'),
summary: z.string().describe('A concise operational summary.'),
rationale: z.string().describe('A brief explanation grounded only in the ticket.'),
requiresHumanReview: z.boolean(),
});
const incidentOutputSchema = z.object({
title: z.string(), executiveSummary: z.string(), customerImpact: z.string(),
status: z.enum(['investigating', 'identified', 'monitoring', 'resolved']),
timelineHighlights: z.array(z.string()).max(5), actionsTaken: z.array(z.string()).max(5),
nextActions: z.array(z.string()).max(5), unresolvedQuestions: z.array(z.string()).max(5),
});
async function runStructuredCapability({ capability, tenantId, userId, prompt, instructions, schema }) {
const result = await generateText({ model: MODEL_ID, instructions, prompt, maxOutputTokens: 400, output: Output.object({ schema }), providerOptions: { gateway: { user: userId, tags: [`tenant:${tenantId}`, `capability:${capability}`, 'env:development'], sort: 'cost' } } });
const gatewayMetadata = result.providerMetadata?.gateway;
return { data: result.output, metadata: { capability, generationId: typeof gatewayMetadata?.generationId === 'string' ? gatewayMetadata.generationId : null, provider: gatewayMetadata?.routing?.finalProvider ?? 'unknown', costUsd: gatewayMetadata?.cost ?? 'unavailable' } };
}
export async function triageTicket(request) {
const parsed = triageRequestSchema.parse(request);
return runStructuredCapability({ capability: 'ticket-triage', tenantId: parsed.tenantId, userId: parsed.userId, schema: triageOutputSchema, instructions: `You are the ticket triage capability for an internal AI platform.
Use this platform policy:
- P1: an active widespread outage or confirmed security compromise.
- P2: major degradation or high risk with material impact.
- P3: limited impact with a workaround or a non-urgent business issue.
- P4: informational or low-impact request.
Choose exactly one category and owner. Base every field only on the supplied ticket. Set requiresHumanReview to true for P1, P2, security, or ambiguous cases.`, prompt: `Triage this ticket:\n\n${parsed.ticket}` });
}
export async function summarizeIncident(request) {
const parsed = incidentRequestSchema.parse(request);
return runStructuredCapability({ capability: 'incident-summary', tenantId: parsed.tenantId, userId: parsed.userId, schema: incidentOutputSchema, instructions: `You are the incident summarization capability for an internal AI platform.
Summarize only facts present in the timeline. Preserve operationally important times, impact, mitigations, and current state. Do not invent a root cause. Use an empty array for missing list information and say unknown in a string field when the timeline does not provide the fact.`, prompt: `Summarize this incident timeline:\n\n${parsed.timeline}` });
}
Run both capabilities together
A shared demo makes the platform boundary visible. Both calls use the same tenant context while returning data shaped for different operational jobs.
- In the editor's Explorer sidebar, select the src/ folder.
- Click the New File button.
- Enter demo.js as the file name.
- Build the two-capability demo by copying this code into src/demo.js:
import { summarizeIncident, triageTicket } from './platform.js';
const context = {
tenantId: 'retail-demo',
userId: 'developer-42',
};
const ticketTriage = await triageTicket({
...context,
ticket:
'Checkout requests are returning HTTP 503 across all regions. Customers cannot place orders and there is no workaround.',
});
const incidentSummary = await summarizeIncident({
...context,
timeline: `09:02 UTC - Checkout error rate increased to 38% after release 2026.10.20-1.
09:05 UTC - Incident declared. Customers could browse but could not complete purchases.
09:11 UTC - On-call identified errors in the payment adapter introduced by the release.
09:14 UTC - Rollback started.
09:21 UTC - Error rate returned below 1%.
09:30 UTC - Team moved the incident to monitoring. Root-cause review is still pending.`,
});
console.log(JSON.stringify({ ticketTriage, incidentSummary }, null, 2));
How does the demo prove reuse?
- The context object gives both calls the same tenant and user attribution.
- The ticket call produces categorical triage fields.
- The incident call produces summary fields plus operational lists.
- The final JSON.stringify call prints both results in one readable object.
- Save src/demo.js.
Before you run the demo, do you expect the two results to share the same data fields or only the same metadata shape?
- Run both capabilities from the integrated terminal by using this command:
npm run demo
What does this command run?
The package script runs src/demo.js with the gateway credential from .env. Each capability makes its own validated model request.
You should see separate ticketTriage and incidentSummary objects. Each result contains a data object plus a metadata object.
The triage metadata should name ticket-triage as its capability. The incident metadata should name incident-summary as its capability.
Strong result. Your shared platform layer now serves two distinct products without changing its execution or observability conventions.
Missing one of the two results?
Check that src/demo.js imports both summarizeIncident and triageTicket from ./platform.js.
Confirm that the incident timeline remains inside one backtick-delimited string.
Help me debug the two-capability demo.
✔️ Awesome, I've got everything!
Great. Double-check that you saved src/demo.js.
ⓧ I'd like to double check the full code
import { summarizeIncident, triageTicket } from './platform.js';
const context = {
tenantId: 'retail-demo',
userId: 'developer-42',
};
const ticketTriage = await triageTicket({
...context,
ticket:
'Checkout requests are returning HTTP 503 across all regions. Customers cannot place orders and there is no workaround.',
});
const incidentSummary = await summarizeIncident({
...context,
timeline: `09:02 UTC - Checkout error rate increased to 38% after release 2026.10.20-1.
09:05 UTC - Incident declared. Customers could browse but could not complete purchases.
09:11 UTC - On-call identified errors in the payment adapter introduced by the release.
09:14 UTC - Rollback started.
09:21 UTC - Error rate returned below 1%.
09:30 UTC - Team moved the incident to monitoring. Root-cause review is still pending.`,
});
console.log(JSON.stringify({ ticketTriage, incidentSummary }, null, 2));
Your platform now proves that one governed execution layer can support multiple capability contracts. Next, you will evaluate whether those contracts produce useful results and connect their generation IDs to gateway evidence.
Evaluate and Observe the Platform
Your shared layer now returns distinct contracts for ticket triage and incident summaries. A valid schema proves each result has the expected shape.
Useful behavior needs a tougher test. This step adds task-specific grading before you trace a generation ID into Vercel AI Gateway Logs.
In this step, get ready to:
- Create four code-graded evaluations for the two governed capabilities.
- Run the evaluation suite to check decisions against task-specific requirements.
- Match a printed generation ID to its operational record in AI Gateway Logs.
Create the evaluation suite
Structured output can satisfy every field requirement while still making the wrong operational decision. Code-based grading checks the exact categories plus the required facts that make each response useful.
- Select the existing src folder in your editor's file tree.
- Create src/eval.js inside that folder.
- Paste this first section into src/eval.js:
import { summarizeIncident, triageTicket } from './platform.js';
const context = { tenantId: 'retail-demo', userId: 'eval-runner' };
function includesText(value, expected) {
const text = Array.isArray(value) ? value.join(' ') : value;
return text.toLowerCase().includes(expected.toLowerCase());
}
const evaluations = [
{ name: 'widespread checkout outage', run: () => triageTicket({ ...context, ticket: 'Every checkout request in every region returns HTTP 503. Customers cannot purchase and there is no workaround.' }), grade: ({ data }) => data.category === 'availability' && data.priority === 'P1' && data.owner === 'sre' && data.requiresHumanReview === true },
{ name: 'confirmed credential compromise', run: () => triageTicket({ ...context, ticket: 'A production API key was committed to a public repository and unauthorized requests are now visible in the audit log.' }), grade: ({ data }) => data.category === 'security' && ['P1', 'P2'].includes(data.priority) && data.owner === 'security' && data.requiresHumanReview === true },
{ name: 'resolved checkout incident', run: () => summarizeIncident({ ...context, timeline: `10:00 UTC - Checkout started returning HTTP 500 for all customers.
10:08 UTC - The team began rolling back the latest release.
10:16 UTC - Checkout error rate returned to normal.
10:25 UTC - The incident commander marked the incident resolved. Root cause review is pending.` }), grade: ({ data }) => data.status === 'resolved' && includesText(data.customerImpact, 'checkout') && includesText(data.actionsTaken, 'rollback') },
{ name: 'database latency under monitoring', run: () => summarizeIncident({ ...context, timeline: `12:00 UTC - API latency increased and some requests timed out.
12:07 UTC - On-call identified a saturated database connection pool.
12:12 UTC - The connection pool limit was increased.
12:20 UTC - Latency returned to normal.
12:28 UTC - The incident moved to monitoring. The team will review pool sizing tomorrow.` }), grade: ({ data }) => data.status === 'monitoring' && includesText(data.actionsTaken, 'connection pool') && data.nextActions.length > 0 },
];
What do these evaluations check?
- The shared context attributes evaluation traffic to retail-demo under the eval-runner user.
- The includesText() helper converts arrays into searchable text.
- The triage graders check exact categories plus policy-approved priorities.
- The incident graders check status values plus required operational facts.
- Save src/eval.js.
- Confirm eval.js appears inside the src folder in your editor's file tree.
File in the wrong location?
Check that eval.js sits directly inside src. A file created beside the src folder cannot resolve the existing relative import.
Help me check the evaluation file location and import path.
- Append the evaluation runner below the closing ]; by pasting this code:
let passed = 0;
for (const evaluation of evaluations) {
try {
const result = await evaluation.run();
const ok = evaluation.grade(result);
passed += ok ? 1 : 0;
console.log(`${ok ? 'PASS' : 'FAIL'} ${evaluation.name}`);
console.log(` generationId: ${result.metadata.generationId}`);
} catch (error) {
console.log(`ERROR ${evaluation.name}: ${error.message}`);
}
}
console.log(`\n${passed}/${evaluations.length} evaluations passed`);
if (passed !== evaluations.length) process.exitCode = 1;
How does the runner grade each case?
- The loop runs each capability request independently.
- Each grade function converts the structured response into a pass or fail result.
- The runner prints each returned generationId for later log inspection.
- A failed total sets a nonzero process exit code for automation.
- Save src/eval.js.
- Confirm the final line sets a nonzero exit code when any evaluation fails.
✔️ Awesome, I've got everything!
Your saved src/eval.js now contains all four cases plus the evaluation runner.
ⓧ I'd like to double check the full code
import { summarizeIncident, triageTicket } from './platform.js';
const context = { tenantId: 'retail-demo', userId: 'eval-runner' };
function includesText(value, expected) {
const text = Array.isArray(value) ? value.join(' ') : value;
return text.toLowerCase().includes(expected.toLowerCase());
}
const evaluations = [
{ name: 'widespread checkout outage', run: () => triageTicket({ ...context, ticket: 'Every checkout request in every region returns HTTP 503. Customers cannot purchase and there is no workaround.' }), grade: ({ data }) => data.category === 'availability' && data.priority === 'P1' && data.owner === 'sre' && data.requiresHumanReview === true },
{ name: 'confirmed credential compromise', run: () => triageTicket({ ...context, ticket: 'A production API key was committed to a public repository and unauthorized requests are now visible in the audit log.' }), grade: ({ data }) => data.category === 'security' && ['P1', 'P2'].includes(data.priority) && data.owner === 'security' && data.requiresHumanReview === true },
{ name: 'resolved checkout incident', run: () => summarizeIncident({ ...context, timeline: `10:00 UTC - Checkout started returning HTTP 500 for all customers.
10:08 UTC - The team began rolling back the latest release.
10:16 UTC - Checkout error rate returned to normal.
10:25 UTC - The incident commander marked the incident resolved. Root cause review is pending.` }), grade: ({ data }) => data.status === 'resolved' && includesText(data.customerImpact, 'checkout') && includesText(data.actionsTaken, 'rollback') },
{ name: 'database latency under monitoring', run: () => summarizeIncident({ ...context, timeline: `12:00 UTC - API latency increased and some requests timed out.
12:07 UTC - On-call identified a saturated database connection pool.
12:12 UTC - The connection pool limit was increased.
12:20 UTC - Latency returned to normal.
12:28 UTC - The incident moved to monitoring. The team will review pool sizing tomorrow.` }), grade: ({ data }) => data.status === 'monitoring' && includesText(data.actionsTaken, 'connection pool') && data.nextActions.length > 0 },
];
let passed = 0;
for (const evaluation of evaluations) {
try {
const result = await evaluation.run();
const ok = evaluation.grade(result);
passed += ok ? 1 : 0;
console.log(`${ok ? 'PASS' : 'FAIL'} ${evaluation.name}`);
console.log(` generationId: ${result.metadata.generationId}`);
} catch (error) {
console.log(`ERROR ${evaluation.name}: ${error.message}`);
}
}
console.log(`\n${passed}/${evaluations.length} evaluations passed`);
if (passed !== evaluations.length) process.exitCode = 1;
Run the task-specific checks
Your $1 monthly key budget stays in place while these four requests run. The supplied examples are designed to keep the complete project below $0.10.
Before you run the suite, make a quick prediction about whether all four task-specific graders will pass.
- Run all four evaluations from the existing integrated terminal by running this command:
npm run eval
What should you see?
The terminal prints one PASS line for each case. The final line reads 4/4 evaluations passed.
That total proves the structured responses satisfy the task-specific checks encoded in src/eval.js.
Did an evaluation fail?
Treat a failed case as a prompt or policy regression. A successful API response only proves that the request completed.
Use the failed case name to inspect its grader in src/eval.js. Compare that requirement with the structured result returned by the capability.
Help me diagnose a failed code-graded evaluation.
- Record one printed generation ID here: your printed generation ID.
Inspect a request in AI Gateway Logs
The printed generation ID connects a local evaluation result to the managed request record. That record provides operational evidence without retaining the prompt or response content.
New log rows can take up to 90 seconds to appear. That pause is normal while AI Gateway ingests the request metadata.
Before you search, predict which request details will connect the local result to the managed model call.
- Return to your Vercel dashboard in the browser.
- Select AI Gateway in the sidebar.
- Select Logs in the AI Gateway sidebar.
- Paste your printed generation ID into the Logs search field.
- Refresh the Logs view after 90 seconds if the matching row is absent.
- Select the matching request row.
- Open its request details to inspect the routing attempts.
What does the log prove?
- The Model field identifies the requested model.
- The Provider field identifies the provider selected by routing.
- The Usage field shows token consumption.
- The Cost field shows the request cost.
- The Duration field shows the request timing.
- The Authentication field shows how the request was authorized.
- The routing details show the provider attempts behind the final result.
You have now connected task-specific quality checks to inspectable gateway evidence. Your two capabilities are validated as products plus observable as managed requests.
Secret mission
Enforce Tenant Capability Policies
Add a tenant capability allowlist that controls which governed capabilities each tenant can call. Prove that an unauthorized incident request stops before it reaches the model or creates a second generation ID.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep these resources, pause request activity, or remove everything. The idle local project is free because costs come from future model requests and reporting writes through Vercel AI Gateway.
Cost warning
Your API key has a $1 monthly soft budget. A request that crosses the limit can still complete.
- Choose Pause - I'll come back to this later when you plan to continue the project.
- Choose Delete - I don't want to use this again when you no longer need the API key.
Resources you used:
- The AI Gateway API key named ai-capability-platform-local with a $1 monthly soft budget.
- Your local project folder named ai-capability-platform.
Keep everything running
No action is required. This option keeps the governed capabilities ready for more local experiments.
- Keep the ai-capability-platform folder with its source files.
- Keep the ai-capability-platform-local API key available through .env.
- Leave the $1 monthly budget enabled to limit future usage.
Pause - I'll come back to this later
Pausing preserves your files plus credentials without creating new requests. An idle project produces no new model calls or reporting writes.
- Stop running the npm scripts.
- Keep the ai-capability-platform folder in its current location.
- Keep the ai-capability-platform-local API key for your return.
- Leave the $1 monthly budget enabled as a spending guard rail.
Delete - I don't want to use this again
Removing credentials can feel final. The two deletions below keep the cloud key separate from your local files.
- Return to the AI Gateway API Keys page in your account.
- Open the key's menu for ai-capability-platform-local.
- Delete the API key from its menu.
- Use Finder to locate the ai-capability-platform folder where you created it.
- Delete the ai-capability-platform folder to remove .env, node_modules, and the local project files.
Nice Work!
Nice Work!
You did it! You built a governed AI platform that turns model calls into reusable capability APIs with validation, evaluation, observability, and tenant policy enforcement.
What you learned:
- Built reusable ticket-triage and incident-summary capabilities behind a shared execution layer. Each request carries user, tenant, capability, and environment attribution.
- Converted free-form model text into Zod-validated structured contracts. Application teams now receive predictable data without choosing a model provider.
- Created four code-graded evaluations for outage, security, resolved-incident, and monitoring cases. Matched generation IDs to Vercel AI Gateway Logs to inspect provider, usage, cost, duration, authentication, and routing.
- Secret Mission: Added tenant capability authorization before the model boundary. Unauthorized requests are blocked before they spend money or send data to a model.
Ready to quiz yourself?