Skip to main content
Developer tutorial — requires Node.js, React, TypeScript, and command-line experience.
Classify incoming legal requests by type and risk, pick the right playbook from your account, and run a full review against the attached document. Two AI calls, one app.
Triage and Route app showing a classified vendor agreement request on the left with playbook review results on the right

What you’ll build

A two-panel webapp where a user describes a legal request, attaches a contract, and clicks “Triage Request.” The app classifies the request (category, risk level, routing lane), selects the most relevant playbook from the user’s GC AI account, and runs that playbook against the document. The left panel shows the classification. The right panel shows every playbook check with pass/flag results and suggested revisions.

API endpoints

MethodEndpointWhat it does
GET/v1/foldersValidates the API key on paste
GET/v1/playbooksFetches available playbooks from the user’s account
POST/v1/chat/completionsClassifies the request and picks a playbook (async, wait=0)
GET/v1/jobs/:idPolls until the classification or playbook run finishes
POST/v1/filesUploads the attached document
GET/v1/files/:idPolls until the file is processed
POST/v1/playbooks/:id/runRuns the selected playbook against the document
Want to skip ahead? View on GitHub or run on Replit.

Prerequisites

  • A GC AI API key (get one from Settings > API in the app)
  • At least one completed playbook in your GC AI account
  • Node.js 18+

Build the app

1

Scaffold the project

Create a new Vite + React + TypeScript project and install Tailwind CSS:
npm create vite@latest api-demo-triage -- --template react-ts
cd api-demo-triage
npm install tailwindcss @tailwindcss/vite
npm install
Open vite.config.ts and add the Tailwind plugin plus an API proxy for development:
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [react(), tailwindcss()],
  server: {
    proxy: {
      "/api": {
        target: "https://app.gc.ai",
        changeOrigin: true,
      },
    },
  },
});
The proxy forwards /api requests to GC AI during development so you don’t hit CORS issues from localhost.Clear out the starter code and set up the theme:
rm src/App.css src/assets -rf
Replace src/index.css with the Tailwind import and a color theme that covers classification badges, routing lanes, and check statuses:
src/index.css
@import "tailwindcss";

@theme {
  --color-surface: #f5f6f8;
  --color-card: #ffffff;
  --color-border: #e2e5ea;
  --color-border-light: #eef0f3;
  --color-ink: #1a2332;
  --color-ink-secondary: #4b5563;
  --color-ink-muted: #8b95a5;
  --color-accent: #2563eb;
  --color-success: #16a34a;
  --color-error: #dc2626;
  --color-error-bg: #fef2f2;

  --color-risk-high: #dc2626;
  --color-risk-high-bg: #fef2f2;
  --color-risk-medium: #d97706;
  --color-risk-medium-bg: #fffbeb;
  --color-risk-low: #0891b2;
  --color-risk-low-bg: #ecfeff;

  --color-route-auto: #16a34a;
  --color-route-auto-bg: #f0fdf4;
  --color-route-junior: #2563eb;
  --color-route-junior-bg: #eff6ff;
  --color-route-senior: #d97706;
  --color-route-senior-bg: #fffbeb;
  --color-route-out: #6b7280;
  --color-route-out-bg: #f9fafb;

  --color-check-pass: #16a34a;
  --color-check-pass-bg: #f0fdf4;
  --color-check-flagged: #d97706;
  --color-check-flagged-bg: #fffbeb;
}
Create two empty files. These are the only two source files we’ll write:
echo '' > src/api.ts
echo 'export default function App() { return <div>hello</div> }' > src/App.tsx
Run npm run dev to confirm the scaffold works before we start building.
2

Build the API client

The API client goes in src/api.ts. It wraps the GC AI endpoints into five functions: validateApiKey, listPlaybooks, uploadFile, classifyRequest, and runPlaybook.Start with the types. This demo has two main shapes: Classification (from the AI) and PlaybookRunResult (from the playbook API):
src/api.ts
export interface Playbook {
  id: string;
  title: string;
  description: string | null;
}

export interface Classification {
  category:
    | "nda"
    | "vendor-agreement"
    | "employment"
    | "ip-licensing"
    | "corporate"
    | "regulatory";
  risk: "high" | "medium" | "low";
  route:
    | "auto-handled"
    | "junior-counsel"
    | "senior-counsel"
    | "routed-out";
  rationale: string;
  suggestedReply: string;
  playbookId: string;
  playbookRationale: string;
}

export interface CheckResult {
  checkId: string;
  checkTitle: string;
  evaluationResult: "Pass" | "Flag" | "Fallback" | "Error";
  analysis?: string;
  revisions?: Array<{
    paragraphId: string;
    revisedText: string;
    revisionTitle: string;
  }>;
}

export interface PlaybookRunResult {
  playbookTitle: string;
  totalChecks: number;
  results: CheckResult[];
  summary: {
    flags: number;
    fallbacks: number;
    passes: number;
    errors: number;
  };
}

interface FileResponse {
  id: string;
  name: string;
  status: "pending" | "processing" | "ready" | "failed";
}

interface JobResponse<T> {
  job_id: string;
  kind: string;
  status: "pending" | "running" | "succeeded" | "failed" | "canceled";
  result: T | null;
  error: { code: string; message: string } | null;
  created_at: string;
  completed_at: string | null;
}

interface ChatResult {
  result: string;
  chat_id: string;
}
Next, a small helper that attaches the API key to every request and throws on non-2xx responses:
src/api.ts
const API_BASE = "/api/external/v1";

async function apiRequest(
  apiKey: string,
  path: string,
  options: RequestInit = {},
): Promise<Response> {
  const res = await fetch(`${API_BASE}${path}`, {
    ...options,
    headers: {
      Authorization: apiKey,
      ...options.headers,
    },
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error(
      (body as { error?: string }).error ??
        `API error ${res.status}: ${res.statusText}`,
    );
  }
  return res;
}
Key validation and playbook listing. validateApiKey checks the key with a lightweight folders call. listPlaybooks fetches all completed playbooks from the user’s account:
src/api.ts
export async function validateApiKey(apiKey: string): Promise<boolean> {
  try {
    const res = await fetch(`${API_BASE}/folders?limit=1`, {
      headers: { Authorization: apiKey },
    });
    return res.ok;
  } catch {
    return false;
  }
}

export async function listPlaybooks(
  apiKey: string,
): Promise<Playbook[]> {
  const res = await apiRequest(apiKey, "/playbooks?limit=500");
  const data: {
    playbooks: Array<{
      id: string;
      title: string;
      description: string | null;
      status: string;
    }>;
  } = await res.json();
  return data.playbooks
    .filter((p) => p.status === "completed")
    .map((p) => ({ id: p.id, title: p.title, description: p.description }));
}
File upload. Same pattern as the heatmap demo: post the file, then poll until it’s ready:
src/api.ts
export async function uploadFile(
  apiKey: string,
  file: File,
): Promise<FileResponse> {
  const form = new FormData();
  form.append("file", file);
  const res = await fetch(`${API_BASE}/files`, {
    method: "POST",
    headers: { Authorization: apiKey },
    body: form,
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error(
      (body as { error?: string }).error ??
        `Upload failed: ${res.status} ${res.statusText}`,
    );
  }
  const uploaded: FileResponse = await res.json();
  return waitForFile(apiKey, uploaded.id);
}

async function waitForFile(
  apiKey: string,
  fileId: string,
): Promise<FileResponse> {
  for (let i = 0; i < 60; i++) {
    const res = await apiRequest(apiKey, `/files/${fileId}`);
    const file: FileResponse = await res.json();
    if (file.status === "ready") return file;
    if (file.status === "failed") throw new Error("File processing failed");
    await new Promise((r) => setTimeout(r, 2000));
  }
  throw new Error("Timed out waiting for file processing");
}
Request classification. This is the first novel piece. The prompt includes the list of playbooks so the model can pick the best match. It returns category, risk, routing lane, and the selected playbook ID:
src/api.ts
function buildClassifyPrompt(playbooks: Playbook[]): string {
  const playbookList = playbooks
    .map((p) => `  - ID: "${p.id}" | Title: "${p.title}"`)
    .join("\n");

  return `You are a legal intake triage system. Classify this incoming legal request, then select the most relevant playbook to run against the attached document.

Return a JSON object with exactly these fields:
- "category": one of "nda", "vendor-agreement", "employment", "ip-licensing", "corporate", "regulatory"
- "risk": "high", "medium", or "low"
- "route": one of "auto-handled" (routine, low-risk items), "junior-counsel" (standard matters), "senior-counsel" (high-risk or high-value), "routed-out" (not a legal matter, forward elsewhere)
- "rationale": one sentence explaining your classification and routing decision
- "suggestedReply": a brief, professional reply to the requester acknowledging receipt and outlining next steps
- "playbookId": the ID of the most relevant playbook from the list below, or "" if none match
- "playbookRationale": one sentence explaining why this playbook was selected

Available playbooks:
${playbookList}

Return ONLY the JSON object, no other text.`;
}

export async function classifyRequest(
  apiKey: string,
  requestText: string,
  playbooks: Playbook[],
): Promise<Classification> {
  const prompt = buildClassifyPrompt(playbooks);
  const res = await apiRequest(apiKey, "/chat/completions?wait=0", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      message: `${prompt}\n\nRequest:\n${requestText}`,
    }),
  });
  const job: JobResponse<ChatResult> = await res.json();
  if (job.status === "failed") {
    throw new Error(job.error?.message ?? "Classification failed");
  }
  return waitForClassification(apiKey, job.job_id);
}

async function waitForClassification(
  apiKey: string,
  jobId: string,
): Promise<Classification> {
  for (let i = 0; i < 90; i++) {
    await new Promise((r) => setTimeout(r, 2000));
    const res = await apiRequest(apiKey, `/jobs/${jobId}`);
    const job: JobResponse<ChatResult> = await res.json();
    if (job.status === "succeeded" && job.result) {
      return parseClassification(job.result.result);
    }
    if (job.status === "failed") {
      throw new Error(job.error?.message ?? "Classification failed");
    }
  }
  throw new Error("Timed out waiting for classification");
}

function parseClassification(text: string): Classification {
  const jsonMatch = text.match(/\{[\s\S]*\}/);
  if (!jsonMatch) throw new Error("Could not parse classification response");
  return JSON.parse(jsonMatch[0]);
}
Playbook run. The second novel piece. Instead of crafting our own review prompt, we call the real playbook API. It runs every check the playbook defines and returns structured results:
src/api.ts
export async function runPlaybook(
  apiKey: string,
  playbookId: string,
  fileIds: string[],
): Promise<PlaybookRunResult> {
  const res = await apiRequest(apiKey, `/playbooks/${playbookId}/run?wait=0`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ file_ids: fileIds }),
  });
  const job: JobResponse<PlaybookRunResult> = await res.json();
  if (job.status === "failed") {
    throw new Error(job.error?.message ?? "Playbook run failed");
  }
  if (job.status === "succeeded" && job.result) {
    return job.result;
  }
  return waitForPlaybook(apiKey, job.job_id);
}

async function waitForPlaybook(
  apiKey: string,
  jobId: string,
): Promise<PlaybookRunResult> {
  for (let i = 0; i < 120; i++) {
    await new Promise((r) => setTimeout(r, 3000));
    const res = await apiRequest(apiKey, `/jobs/${jobId}`);
    const job: JobResponse<PlaybookRunResult> = await res.json();
    if (job.status === "succeeded" && job.result) {
      return job.result;
    }
    if (job.status === "failed") {
      throw new Error(job.error?.message ?? "Playbook run failed");
    }
  }
  throw new Error("Timed out waiting for playbook run");
}
That’s the whole API layer. Five exported functions: validateApiKey, listPlaybooks, uploadFile, classifyRequest, and runPlaybook. The playbook polling uses a 3-second interval and longer timeout since playbook runs with many checks can take a few minutes.
3

App shell: header, key validation, and playbook loading

Now we start building src/App.tsx. This first pass sets up the header with the API key input, loads playbooks once the key validates, and adds the footer. The layout is wider than the heatmap demo (max-w-5xl instead of max-w-3xl) because we’ll use a two-column layout for results.
src/App.tsx
import { useCallback, useEffect, useState } from "react";
import {
  validateApiKey,
  listPlaybooks,
  uploadFile,
  classifyRequest,
  runPlaybook,
} from "./api";
import type {
  Playbook,
  Classification,
  CheckResult,
  PlaybookRunResult,
} from "./api";

function App() {
  const [apiKey, setApiKey] = useState(
    () => localStorage.getItem("gcai_api_key") ?? "",
  );
  const [keyValid, setKeyValid] = useState<boolean | null>(null);
  const [keyChecking, setKeyChecking] = useState(false);
  const [playbooks, setPlaybooks] = useState<Playbook[]>([]);

  const checkKey = useCallback(async (key: string) => {
    if (!key.trim()) {
      setKeyValid(null);
      return;
    }
    setKeyChecking(true);
    const valid = await validateApiKey(key.trim());
    setKeyValid(valid);
    setKeyChecking(false);
    if (valid) {
      localStorage.setItem("gcai_api_key", key.trim());
      listPlaybooks(key.trim()).then(setPlaybooks).catch(() => {});
    }
  }, []);

  useEffect(() => {
    if (apiKey) checkKey(apiKey);
  }, []);

  function handleKeyChange(e: React.ChangeEvent<HTMLInputElement>) {
    setApiKey(e.target.value);
    setKeyValid(null);
  }

  return (
    <div className="flex min-h-screen flex-col bg-surface">
      <header className="border-b border-border bg-card px-6 py-3">
        <div className="mx-auto flex max-w-5xl items-center justify-between gap-4">
          <h1 className="shrink-0 text-base font-semibold text-ink">
            Triage &amp; Route
          </h1>
          <form
            onSubmit={(e) => { e.preventDefault(); checkKey(apiKey); }}
            className="flex items-center gap-2"
          >
            <div className="relative">
              <input
                type="password"
                value={apiKey}
                onChange={handleKeyChange}
                onBlur={() => checkKey(apiKey)}
                placeholder="Paste your GC AI API key"
                className="w-56 rounded border border-border bg-surface px-3 py-1.5 pr-8 font-mono text-xs text-ink placeholder:text-ink-muted focus:border-accent focus:ring-1 focus:ring-accent focus:outline-none"
              />
              <span className="absolute top-1/2 right-2.5 -translate-y-1/2">
                {keyChecking && (
                  <span className="block size-3 animate-spin rounded-full border border-border border-t-accent" />
                )}
                {!keyChecking && keyValid === true && (
                  <span className="text-xs text-success">&#10003;</span>
                )}
                {!keyChecking && keyValid === false && (
                  <span className="text-xs text-error">&#10007;</span>
                )}
              </span>
            </div>
            <a
              href="https://app.gc.ai/settings/api"
              target="_blank"
              rel="noopener noreferrer"
              className="whitespace-nowrap text-xs text-ink-muted hover:text-accent"
            >
              Get a key
            </a>
          </form>
        </div>
      </header>

      <main className="mx-auto flex w-full max-w-5xl flex-1 flex-col px-6 py-6">
        {/* We'll fill this in over the next steps */}
      </main>

      <footer className="border-t border-border px-6 py-3 text-center text-xs text-ink-muted">
        Powered by the{" "}
        <a
          href="https://docs.gc.ai/api-reference/introduction"
          target="_blank"
          rel="noopener noreferrer"
          className="text-accent hover:underline"
        >
          GC AI API
        </a>
      </footer>
    </div>
  );
}

export default App;
When the key validates, listPlaybooks runs in the background. The playbooks are stored in state so the classification prompt can include them later. You won’t see them in the UI yet.
App header showing Triage and Route title with a validated API key and empty request form
4

Request form

Add a RequestForm component above the App function. It has a text area for describing the request and an optional file upload:
src/App.tsx (above App)
function RequestForm({
  onSubmit,
  disabled,
}: {
  onSubmit: (requestText: string, file?: File) => void;
  disabled: boolean;
}) {
  const [text, setText] = useState("");
  const [file, setFile] = useState<File | null>(null);
  const [dragOver, setDragOver] = useState(false);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!text.trim()) return;
    onSubmit(text.trim(), file ?? undefined);
  }

  const handleDrop = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setDragOver(false);
    const dropped = e.dataTransfer.files[0];
    if (dropped) setFile(dropped);
  }, []);

  return (
    <form onSubmit={handleSubmit} className="flex w-full flex-col gap-4">
      <div>
        <label htmlFor="request-text" className="mb-1.5 block text-sm font-medium text-ink">
          Describe your request
        </label>
        <textarea
          id="request-text"
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder='e.g. "We need to sign an NDA with Acme Corp before sharing our product roadmap. They sent over their template, attached."'
          rows={4}
          disabled={disabled}
          className="w-full resize-none rounded-lg border border-border bg-card px-3 py-2 text-sm text-ink placeholder:text-ink-muted focus:border-accent focus:ring-1 focus:ring-accent focus:outline-none disabled:opacity-50"
        />
      </div>

      <div>
        <label className="mb-1.5 block text-sm font-medium text-ink">
          Attach a document{" "}
          <span className="font-normal text-ink-muted">(optional)</span>
        </label>
        <div
          onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
          onDragLeave={() => setDragOver(false)}
          onDrop={handleDrop}
          onClick={() => { if (!disabled) document.getElementById("file-input")?.click(); }}
          className={`flex w-full cursor-pointer items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors ${
            dragOver ? "border-accent bg-accent/5" : "border-border hover:border-ink-muted"
          } ${disabled ? "pointer-events-none opacity-50" : ""}`}
        >
          {file ? (
            <div className="flex items-center gap-2 text-sm text-ink-secondary">
              <svg className="size-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                <path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4" />
              </svg>
              <span>{file.name}</span>
              <button type="button" onClick={(e) => { e.stopPropagation(); setFile(null); }} className="ml-1 text-ink-muted hover:text-error">
                &times;
              </button>
            </div>
          ) : (
            <p className="text-sm text-ink-muted">
              Drop a file here, or <span className="font-medium text-accent">browse</span>
            </p>
          )}
        </div>
        <input id="file-input" type="file" accept=".pdf,.docx,.doc,.txt"
          onChange={(e) => { const f = e.target.files?.[0]; if (f) setFile(f); }}
          className="hidden"
        />
      </div>

      <button type="submit" disabled={disabled || !text.trim()}
        className="self-start rounded-lg bg-accent px-5 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent/90 disabled:opacity-50">
        Triage Request
      </button>
    </form>
  );
}
Also add a RequestSummary component that displays the submitted request after the form is replaced:
src/App.tsx (above App)
interface RequestContext {
  requestText: string;
  fileName?: string;
}

function RequestSummary({
  request,
  onReset,
}: {
  request: RequestContext;
  onReset: () => void;
}) {
  return (
    <div className="w-full rounded-lg border border-border-light bg-card p-4 shadow-sm">
      <div className="flex items-start justify-between gap-4">
        <div className="min-w-0 flex-1">
          <p className="text-sm leading-relaxed text-ink-secondary">
            {request.requestText}
          </p>
          {request.fileName && (
            <div className="mt-2 flex items-center gap-1.5 text-xs text-ink-muted">
              <svg className="size-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
                <path strokeLinecap="round" strokeLinejoin="round"
                  d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
              </svg>
              <span>{request.fileName}</span>
            </div>
          )}
        </div>
        <button onClick={onReset} className="shrink-0 text-xs text-ink-muted hover:text-accent">
          New request
        </button>
      </div>
    </div>
  );
}
Request form with a text area, file upload zone with a selected PDF, and Triage Request button
5

Classification panel

Add the classification panel that displays on the left side of the results. It shows the category, risk level, routing lane, the selected playbook, and a suggested reply:
src/App.tsx (above App)
const riskStyles = {
  high: { badge: "bg-risk-high-bg text-risk-high", label: "High Risk" },
  medium: { badge: "bg-risk-medium-bg text-risk-medium", label: "Medium Risk" },
  low: { badge: "bg-risk-low-bg text-risk-low", label: "Low Risk" },
};

const routeStyles: Record<Classification["route"], { badge: string; label: string }> = {
  "auto-handled": { badge: "bg-route-auto-bg text-route-auto", label: "Auto-Handled" },
  "junior-counsel": { badge: "bg-route-junior-bg text-route-junior", label: "Junior Counsel" },
  "senior-counsel": { badge: "bg-route-senior-bg text-route-senior", label: "Senior Counsel" },
  "routed-out": { badge: "bg-route-out-bg text-route-out", label: "Routed Out" },
};

const categoryLabels: Record<Classification["category"], string> = {
  nda: "NDA",
  "vendor-agreement": "Vendor Agreement",
  employment: "Employment",
  "ip-licensing": "IP & Licensing",
  corporate: "Corporate",
  regulatory: "Regulatory",
};

function ClassificationPanel({
  classification,
  selectedPlaybook,
}: {
  classification: Classification;
  selectedPlaybook?: Playbook;
}) {
  const risk = riskStyles[classification.risk];
  const route = routeStyles[classification.route];

  return (
    <>
      <div className="flex items-center justify-between border-b border-border-light px-5 py-3">
        <h2 className="text-sm font-semibold text-ink">Classification</h2>
        <div className="flex flex-wrap gap-1.5">
          <span className="rounded bg-surface px-2 py-0.5 text-xs font-semibold text-ink">
            {categoryLabels[classification.category]}
          </span>
          <span className={`rounded px-2 py-0.5 text-xs font-medium ${risk.badge}`}>
            {risk.label}
          </span>
        </div>
      </div>

      <div className="flex-1 overflow-y-auto p-5">
        <div className="mb-4 flex items-center gap-2">
          <span className={`rounded px-2 py-0.5 text-xs font-medium ${route.badge}`}>
            &rarr; {route.label}
          </span>
        </div>

        <p className="text-sm leading-relaxed text-ink-secondary">
          {classification.rationale}
        </p>

        {selectedPlaybook && (
          <div className="mt-4 rounded-lg border border-accent/20 bg-accent/5 p-3">
            <p className="mb-1 text-xs font-medium text-accent">Selected playbook</p>
            <p className="text-sm font-medium text-ink">{selectedPlaybook.title}</p>
            {classification.playbookRationale && (
              <p className="mt-1 text-xs leading-relaxed text-ink-muted">
                {classification.playbookRationale}
              </p>
            )}
          </div>
        )}

        <div className="mt-4 rounded-lg bg-surface p-3">
          <p className="mb-1 text-xs font-medium text-ink-muted">Suggested reply</p>
          <p className="text-sm leading-relaxed text-ink-secondary">
            {classification.suggestedReply}
          </p>
        </div>
      </div>
    </>
  );
}
The category and risk badges sit in the panel’s header bar. The routing lane, rationale, selected playbook, and suggested reply fill the body. The “Selected playbook” block has a distinct accent-colored border so it stands out as the routing decision.
6

Playbook results panel

Add the playbook panel that displays on the right side. Each check is collapsible, showing just the title and Pass/Flag badge until clicked. The analysis text is cleaned to strip internal citation tags:
src/App.tsx (above App)
function cleanText(text: string): string {
  return text
    .replace(/<cite[^>]*\/>/g, "")
    .replace(/\*\*/g, "")
    .trim();
}

function CheckCard({ check }: { check: CheckResult }) {
  const [expanded, setExpanded] = useState(false);
  const passed = check.evaluationResult === "Pass";
  const flagged =
    check.evaluationResult === "Flag" ||
    check.evaluationResult === "Fallback";
  const hasDetails =
    (check.analysis && check.analysis.trim().length > 0) ||
    (check.revisions && check.revisions.length > 0);

  return (
    <div className={`rounded-lg border border-border-light border-l-[3px] bg-surface ${
      passed ? "border-l-check-pass"
        : flagged ? "border-l-check-flagged"
        : "border-l-border"
    }`}>
      <button type="button"
        onClick={() => hasDetails && setExpanded(!expanded)}
        className={`flex w-full items-center gap-2 px-4 py-3 text-left ${hasDetails ? "cursor-pointer" : "cursor-default"}`}
      >
        {passed ? (
          <svg className="size-4 shrink-0 text-check-pass" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
            <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
          </svg>
        ) : flagged ? (
          <svg className="size-4 shrink-0 text-check-flagged" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
            <path strokeLinecap="round" strokeLinejoin="round"
              d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
          </svg>
        ) : (
          <svg className="size-4 shrink-0 text-ink-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
            <path strokeLinecap="round" strokeLinejoin="round"
              d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
          </svg>
        )}
        <span className="flex-1 text-sm font-medium text-ink">{check.checkTitle}</span>
        <span className={`shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium ${
          passed ? "bg-check-pass-bg text-check-pass"
            : flagged ? "bg-check-flagged-bg text-check-flagged"
            : "bg-card text-ink-muted"
        }`}>
          {check.evaluationResult}
        </span>
        {hasDetails && (
          <svg className={`size-4 shrink-0 text-ink-muted transition-transform ${expanded ? "rotate-180" : ""}`}
            fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
            <path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
          </svg>
        )}
      </button>

      {expanded && (
        <div className="border-t border-border-light px-4 pb-4 pt-3">
          {check.analysis && (
            <p className="pl-6 text-[13px] leading-relaxed text-ink-secondary">
              {cleanText(check.analysis)}
            </p>
          )}
          {check.revisions?.map((rev, i) => (
            <p key={i} className="mt-2 pl-6 text-[13px] leading-relaxed text-check-flagged">
              &rarr; {rev.revisionTitle}
            </p>
          ))}
        </div>
      )}
    </div>
  );
}

function PlaybookPanel({ result }: { result: PlaybookRunResult }) {
  return (
    <>
      <div className="flex items-center justify-between border-b border-border-light px-5 py-3">
        <h2 className="text-sm font-semibold text-ink">{result.playbookTitle}</h2>
        <div className="flex gap-2 text-xs">
          {result.summary.flags > 0 && (
            <span className="rounded bg-check-flagged-bg px-1.5 py-0.5 font-medium text-check-flagged">
              {result.summary.flags} Flagged
            </span>
          )}
          {result.summary.passes > 0 && (
            <span className="rounded bg-check-pass-bg px-1.5 py-0.5 font-medium text-check-pass">
              {result.summary.passes} Pass
            </span>
          )}
          {result.summary.fallbacks > 0 && (
            <span className="rounded bg-risk-medium-bg px-1.5 py-0.5 font-medium text-risk-medium">
              {result.summary.fallbacks} Fallback
            </span>
          )}
        </div>
      </div>

      <div className="flex-1 space-y-2 overflow-y-auto p-5">
        {result.results.map((check) => (
          <CheckCard key={check.checkId} check={check} />
        ))}
      </div>
    </>
  );
}
cleanText strips <cite id="..."/> tags and ** bold markers that the playbook system uses internally for citations and emphasis. These are useful in the product UI but would look like raw markup in our demo.
7

Wire it up

The last piece is the state machine and the three-section layout that brings everything together.Define the status type above the App function:
src/App.tsx (above App)
type Status =
  | { step: "idle" }
  | { step: "classifying"; request: RequestContext }
  | { step: "uploading"; request: RequestContext; classification: Classification; file: File }
  | { step: "reviewing"; request: RequestContext; classification: Classification }
  | { step: "done"; request: RequestContext; classification: Classification; playbookResult?: PlaybookRunResult }
  | { step: "error"; message: string };
Each step carries everything the UI needs. The request context travels through every state so the summary bar always has it. The classification appears once classification finishes and stays visible through upload and review.Add the state, handler, and derived values inside App:
const [status, setStatus] = useState<Status>({ step: "idle" });

const selectedPlaybook =
  status.step !== "idle" && status.step !== "error" && status.step !== "classifying"
    ? playbooks.find(
        (p) => p.id === (status as { classification: Classification }).classification.playbookId,
      )
    : undefined;

async function handleSubmit(requestText: string, file?: File) {
  const key = apiKey.trim();
  const request: RequestContext = { requestText, fileName: file?.name };
  try {
    setStatus({ step: "classifying", request });
    const classification = await classifyRequest(key, requestText, playbooks);

    if (!file || !classification.playbookId) {
      setStatus({ step: "done", request, classification });
      return;
    }

    setStatus({ step: "uploading", request, classification, file });
    const uploaded = await uploadFile(key, file);

    setStatus({ step: "reviewing", request, classification });
    const playbookResult = await runPlaybook(key, classification.playbookId, [uploaded.id]);

    setStatus({ step: "done", request, classification, playbookResult });
  } catch (err) {
    setStatus({
      step: "error",
      message: err instanceof Error ? err.message : "Something went wrong",
    });
  }
}

function reset() {
  setStatus({ step: "idle" });
}

const hasClassification =
  status.step === "uploading" || status.step === "reviewing" || status.step === "done";
const hasRequest = status.step !== "idle" && status.step !== "error";
The handler runs four steps in sequence: classify, upload, run playbook, done. If there’s no file or no matching playbook, it short-circuits to done after classification.Finally, fill in the <main> element. The layout has three sections: the request summary on top, then a two-column grid with classification on the left and playbook results on the right:
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col px-6 py-6">
  {keyValid === false && (
    <div className="mb-4 rounded border border-error/20 bg-error-bg px-4 py-2 text-sm text-error">
      Invalid API key. Check your key in{" "}
      <a href="https://app.gc.ai/settings/api" target="_blank" rel="noopener noreferrer" className="underline">
        Settings &rarr; API
      </a>
    </div>
  )}

  {!keyValid && !keyChecking && status.step === "idle" && (
    <div className="flex flex-1 flex-col items-center justify-center">
      <div className="rounded-lg border border-dashed border-border p-16 text-center">
        <p className="text-sm text-ink-secondary">Enter your GC AI API key above to get started</p>
        <p className="mt-1 text-xs text-ink-muted">Classify legal requests and route them to the right playbook</p>
      </div>
    </div>
  )}

  {keyValid === true && status.step === "idle" && (
    <div className="mx-auto w-full max-w-2xl pt-4">
      <RequestForm onSubmit={handleSubmit} disabled={false} />
    </div>
  )}

  {hasRequest && (
    <>
      <RequestSummary
        request={(status as { request: RequestContext }).request}
        onReset={reset}
      />

      <div className="mt-4 grid min-h-0 flex-1 grid-cols-[2fr_3fr] gap-6 pb-4">
        {/* Left: Classification */}
        <div className="flex flex-col overflow-hidden rounded-lg border border-border-light bg-card shadow-sm">
          {status.step === "classifying" && (
            <div className="flex flex-1 flex-col items-center justify-center gap-3">
              <div className="size-8 animate-spin rounded-full border-2 border-border border-t-accent" />
              <p className="text-sm text-ink-secondary">Classifying request...</p>
            </div>
          )}
          {hasClassification && (
            <ClassificationPanel
              classification={(status as { classification: Classification }).classification}
              selectedPlaybook={selectedPlaybook}
            />
          )}
        </div>

        {/* Right: Playbook */}
        <div className="flex flex-col overflow-hidden rounded-lg border border-border-light bg-card shadow-sm">
          {status.step === "classifying" && (
            <div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-ink-muted">
              Waiting for classification...
            </div>
          )}
          {(status.step === "uploading" || status.step === "reviewing") && (
            <>
              <div className="border-b border-border-light px-5 py-3">
                <h2 className="text-sm font-semibold text-ink">
                  {selectedPlaybook?.title ?? "Playbook Review"}
                </h2>
              </div>
              <div className="flex flex-1 flex-col items-center justify-center gap-3">
                <div className="size-8 animate-spin rounded-full border-2 border-border border-t-accent" />
                <p className="text-sm text-ink-secondary">
                  {status.step === "uploading"
                    ? "Uploading document..."
                    : "Running playbook, this may take a few minutes..."}
                </p>
              </div>
            </>
          )}
          {status.step === "done" && status.playbookResult && (
            <PlaybookPanel result={status.playbookResult} />
          )}
          {status.step === "done" && !status.playbookResult && (
            <div className="flex flex-1 flex-col items-center justify-center gap-2 p-5 text-center">
              <p className="text-sm text-ink-muted">
                {(status as { classification: Classification }).classification.playbookId
                  ? "No document attached, so no playbook was run."
                  : "No matching playbook found for this request type."}
              </p>
            </div>
          )}
        </div>
      </div>
    </>
  )}

  {status.step === "error" && (
    <div className="flex flex-1 flex-col items-center justify-center gap-3">
      <div className="rounded border border-error/20 bg-error-bg px-4 py-3 text-sm text-error">
        {(status as { message: string }).message}
      </div>
      <button onClick={reset} className="text-sm text-accent hover:underline">Try again</button>
    </div>
  )}
</main>
Run npm run dev, paste your API key, describe a request like “Our vendor Cloudtop is requiring us to sign their SaaS MSA, contract attached,” upload the PDF, and click Triage Request. The classification should appear in a few seconds on the left. The playbook results fill in on the right once the review finishes, which can take a few minutes for playbooks with many checks.
Finished app showing a classified vendor agreement on the left with the SaaS Master Service Agreement playbook selected, and collapsible check results on the right

Source code

GitHub

Clone the repo and run locally

Replit

Run it in the browser