Build a TypeScript console where OpenAI proposes a constrained incident workflow, an operator reviews the exact model context, and the server executes only an explicitly approved stored plan.
What You Will Build
This tutorial creates a small Next.js App Router application for human-approved workflow automation. An operator supplies incident details and chooses which runbook notes become model context. A server route sends that explicit object to OpenAI, parses the returned JSON, validates it with Zod, and stores a proposal. Nothing runs at plan-generation time.
The proposal is deliberately limited to two actions: create_task, which records a local task for this demonstration, and send_webhook, which posts a predefined event to one configured HTTPS destination. The model cannot invent another action type and cannot select a URL. An approval request contains only the plan ID; the server reloads the authoritative stored plan before executing it.
OpenAI’s current developer resources include guidance for GPT-5.6, the Responses API, webhooks, conversation state, streaming, background processing, and multi-agent patterns. This example stays intentionally narrow: it uses a server-side OpenAI JavaScript client for planning and keeps side-effect policy in application code. Review the official documentation before adding any of those broader capabilities.
Prerequisites
- A current Node.js LTS installation and npm.
- An OpenAI API key stored only in a server-side environment variable.
- Working knowledge of TypeScript, React, and Next.js Route Handlers.
- An HTTPS webhook URL you control if you want to test webhook delivery.
Step 1: Create the Next.js Project
Create an App Router project and install the OpenAI SDK and Zod. Zod is used for runtime checks because TypeScript types do not validate HTTP bodies or model output at runtime.
npx create-next-app@latest ai-workflow-console --typescript --eslint --app --src-dir --import-alias "@/*"
cd ai-workflow-console
npm install openai zod
mkdir -p src/lib src/app/api/plans src/app/api/plans/[id]/executeCreate .env.local. Do not expose the API key with a NEXT_PUBLIC_ prefix and do not import the OpenAI SDK into a Client Component.
OPENAI_API_KEY=replace-with-your-server-side-key
OPENAI_MODEL=gpt-5.6
ALLOWED_WEBHOOK_HOSTS=hooks.example.com
WEBHOOK_URL=https://hooks.example.com/incident-events
WEBHOOK_SHARED_SECRET=replace-with-a-long-random-value
MAX_WORKFLOW_ACTIONS=3The webhook URL is application configuration, not model output. The allowlist supplies a second check that the configured URL points to an expected host.
Step 2: Define the Workflow Contract
Create src/lib/workflow.ts. The request schema limits the operator input. The action schema is a discriminated union, so each action must match one approved shape. Plan identifiers, timestamps, and status are created by trusted server code rather than accepted from the model.
import { z } from "zod";
const configuredMaxActions = Number(process.env.MAX_WORKFLOW_ACTIONS ?? "3");
const maxActions = Number.isInteger(configuredMaxActions) && configuredMaxActions > 0
? configuredMaxActions
: 3;
export const workflowRequestSchema = z.object({
incident: z.string().trim().min(20).max(4000),
service: z.string().trim().min(2).max(100),
urgency: z.enum(["low", "medium", "high", "critical"]),
selectedRunbookNotes: z.array(z.string().trim().min(1).max(800)).max(5),
});
export const workflowActionSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("create_task"),
title: z.string().trim().min(5).max(180),
description: z.string().trim().min(10).max(2000),
assigneeTeam: z.enum(["platform", "application", "security", "support"]),
}),
z.object({
type: z.literal("send_webhook"),
event: z.enum([
"incident.plan_approved",
"incident.escalation_requested",
"incident.status_update",
]),
message: z.string().trim().min(5).max(1000),
}),
]);
export const modelPlanSchema = z.object({
summary: z.string().trim().min(20).max(1200),
reasoning: z.string().trim().min(20).max(2000),
confidence: z.enum(["low", "medium", "high"]),
warnings: z.array(z.string().trim().min(1).max(300)).max(8),
actions: z.array(workflowActionSchema).min(1).max(maxActions),
});
export const workflowPlanSchema = modelPlanSchema.extend({
id: z.string().uuid(),
createdAt: z.string().datetime(),
status: z.enum(["proposed", "executing", "executed", "failed"]),
});
export type WorkflowAction = z.infer<typeof workflowActionSchema>;
export type WorkflowPlan = z.infer<typeof workflowPlanSchema>;
export function parseModelJson(content: string | null): unknown {
if (!content) throw new Error("The model returned an empty response.");
try {
return JSON.parse(content);
} catch {
throw new Error("The model did not return valid JSON.");
}
}
export function getWebhookUrl(): URL {
const rawUrl = process.env.WEBHOOK_URL;
if (!rawUrl) throw new Error("WEBHOOK_URL is required.");
const url = new URL(rawUrl);
if (url.protocol !== "https:") {
throw new Error("WEBHOOK_URL must use HTTPS.");
}
const allowedHosts = new Set(
(process.env.ALLOWED_WEBHOOK_HOSTS ?? "")
.split(",")
.map((value) => value.trim().toLowerCase())
.filter(Boolean),
);
if (!allowedHosts.has(url.hostname.toLowerCase())) {
throw new Error("WEBHOOK_URL hostname is not allowlisted.");
}
return url;
}The schema is the enforcement boundary. A prompt can ask the model to be cautious, but the server must still reject unknown actions, malformed values, and oversized fields. This example also avoids a model-controlled destination field entirely.
Step 3: Store Proposed Plans Locally
Create src/lib/plan-store.ts. This in-memory store makes the lifecycle runnable on one local development process. It is not durable storage and should be replaced before a real deployment.
import type { WorkflowPlan } from "@/lib/workflow";
const plans = new Map<string, WorkflowPlan>();
export function savePlan(plan: WorkflowPlan): WorkflowPlan {
plans.set(plan.id, plan);
return plan;
}
export function findPlan(id: string): WorkflowPlan | undefined {
return plans.get(id);
}
export function updatePlan(
id: string,
update: (plan: WorkflowPlan) => WorkflowPlan,
): WorkflowPlan | undefined {
const existing = plans.get(id);
if (!existing) return undefined;
const next = update(existing);
plans.set(id, next);
return next;
}Step 4: Generate a Constrained AI Proposal
Create src/app/api/plans/route.ts. The route constructs visibleContext explicitly and returns the same object to the browser. It asks the model for JSON, then validates the JSON before storing a proposed plan. Invalid model output is rejected rather than executed.
import { NextRequest } from "next/server";
import { OpenAI } from "openai";
import { ZodError } from "zod";
import { savePlan } from "@/lib/plan-store";
import {
modelPlanSchema,
parseModelJson,
workflowRequestSchema,
workflowPlanSchema,
} from "@/lib/workflow";export const...Continue Reading
Log in for free to read the rest of this article and access exclusive AI tools.
Log in / Register