cd /news/ai-products/openais-decisions-api-is-in-public-b… · home › topics › ai-products › article
[ARTICLE · art-146961] src=dev.to ↗ pub= topic=ai-products verified=true sentiment=· neutral

OpenAI’s Decisions API Is in Public Beta. Build a Typed Router in TypeScript.

OpenAI released its Decisions API in public beta on October 6, 2026, offering GPT-6 Luna with three output types — predicates, choices and scores — and claiming decisions can run up to 10× faster than GPT-6 Luna through the Responses API. A developer published a TypeScript router that validates the API's typed output at the application boundary, checking for a single named answer, handling refusals and unexpected types, and routing unknown or duplicate answers to a review queue instead of dispatching them directly. The demo runs on Node.js 22.20.0 with built-in type stripping and uses ten invented fixtures rather than live API calls.

by read6 min views2 publishedOct 7, 2026

October 7, 2026 — news: OpenAI made the Decisions API available in public beta on October 6. The announcement names GPT-6 Luna and three outputs: predicates, choices and scores. OpenAI claims decisions can be up to 10× faster than GPT-6 Luna through the Responses API. That is a vendor claim, not a measurement from this build. Primary announcement.

The engineering idea I care about is smaller than an agent loop: hand the model a bounded question, then let application code decide what happens next.

A support message arrives. You need a queue, not a paragraph.

The dangerous implementation is one line:

dispatch(answer.choice);

The useful implementation has a boundary. It knows the expected question name, the allowed routes, the answer type, and what to do when the model refuses or the response is unusable.

Let's build that boundary in TypeScript.

Honesty note: this is a local consumer-side demo with ten invented response fixtures. It makes no OpenAI API requests and needs no API key. It tests our routing policy, not GPT-6 Luna's accuracy, calibration, speed or refusal rate. The threshold is illustrative.

The October 6 API changelog records the beta release at /v1/decisions. This is an availability update following the earlier preview, not a claim that the concept first appeared yesterday.

The current Decisions guide says the beta supports gpt-6-luna and text/image inputs. A predicate estimates whether a condition is true; a choice selects from your fixed values; a score evaluates ordered levels. A question's name identifies its answer. Refusals need their own handling.

For a support router, a choice is a natural fit. The result can assign work to billing, technical support, or a review queue. My design keeps refunds and other consequential actions outside that router.

Typed output shrinks the space of normal answers. It does not remove the need to validate external data or define failure behavior.

We accept unknown, rather than pretending the network response already has a trusted TypeScript type.

Then we check:

department. Notice the order. A high confidence cannot rescue an unknown route. A duplicate answer cannot silently win because it happened to appear first.

The code intentionally validates only the fields our application uses. It is not a complete validator for every field in the API response.

Save this as router.ts. I ran it on Node.js 22.20.0 using built-in TypeScript type stripping. No npm packages are required for execution.

node router.ts

Type stripping runs TypeScript syntax; it does not perform a type check.

import assert from 'node:assert/strict';

const routes = ['billing', 'technical', 'other'] as const;
type Route = typeof routes[number];
type Result = { queue: Route | 'review'; reason: string };
const object = (x: unknown): x is Record<string, unknown> =>
  typeof x === 'object' && x !== null && !Array.isArray(x);

// This validates the application boundary, not every API response field.
function routeDecision(raw: unknown): Result {
  const review = (reason: string): Result => ({ queue: 'review', reason });
  if (!object(raw) || !Array.isArray(raw.answers))
    return review('malformed envelope');
  const matches = raw.answers.filter(
    x => object(x) && x.name === 'department'
  );
  if (matches.length !== 1) return review('missing or duplicate answer');
  const answer = matches[0] as Record<string, unknown>;
  if (answer.type === 'refusal') return review('model refusal');
  if (answer.type !== 'choice') return review('unexpected answer type');
  if (typeof answer.choice !== 'string' ||
      !routes.includes(answer.choice as Route))
    return review('unknown route');
  const confidence = answer.confidence;
  if (typeof confidence !== 'number' || !Number.isFinite(confidence) ||
      confidence < 0 || confidence > 1)
    return review('invalid confidence');
  // Illustrative policy only; choose a threshold using labeled evaluations.
  if (confidence < .8) return review('below local threshold');
  if (answer.choice === 'other') return review('fallback category');
  return { queue: answer.choice as Route, reason: 'validated choice' };
}

const envelope = (answer: unknown) => ({ answers: [answer] });
const choice = (value: string, confidence: number) =>
  envelope({ type: 'choice', name: 'department', choice: value, confidence });
const cases: [string, unknown, Result][] = [
  ['valid', choice('billing', .94), { queue: 'billing', reason: 'validated choice' }],
  ['low', choice('technical', .6), { queue: 'review', reason: 'below local threshold' }],
  ['fallback', choice('other', .95), { queue: 'review', reason: 'fallback category' }],
  ['refusal', envelope({ type: 'refusal', name: 'department' }),
    { queue: 'review', reason: 'model refusal' }],
  ['unknown', choice('refund_now', .99), { queue: 'review', reason: 'unknown route' }],
  ['nan', choice('billing', NaN), { queue: 'review', reason: 'invalid confidence' }],
  ['missing', { answers: [] }, { queue: 'review', reason: 'missing or duplicate answer' }],
  ['duplicate', { answers: [
    { type: 'choice', name: 'department', choice: 'billing', confidence: .9 },
    { type: 'choice', name: 'department', choice: 'technical', confidence: .9 }
  ] }, { queue: 'review', reason: 'missing or duplicate answer' }],
  ['wrong-type', envelope({ type: 'predicate', name: 'department', probability: .9 }),
    { queue: 'review', reason: 'unexpected answer type' }],
  ['malformed', null, { queue: 'review', reason: 'malformed envelope' }],
];
for (const [name, fixture, expected] of cases) {
  const result = routeDecision(fixture);
  assert.deepEqual(result, expected);
  console.log(name + ': ' + result.queue + ' (' + result.reason + ')');
}
console.log('All ' + cases.length + ' fixture assertions passed.');
valid: billing (validated choice)
low: review (below local threshold)
fallback: review (fallback category)
refusal: review (model refusal)
unknown: review (unknown route)
nan: review (invalid confidence)
missing: review (missing or duplicate answer)
duplicate: review (missing or duplicate answer)
wrong-type: review (unexpected answer type)
malformed: review (malformed envelope)
All 10 fixture assertions passed.

Nine review outcomes do not mean the model fails 90% of the time. We deliberately constructed nine exceptional inputs. This is a boundary test, not a representative dataset.

The missing and duplicate cases are especially useful. Neither should become a default billing route. The NaN case catches a JavaScript detail: numeric type alone does not establish a usable number.

The refusal case gets a distinct reason. Keeping reasons explicit lets you measure whether review load comes from uncertain classifications, provider refusals, invalid data, or a taxonomy that needs work.

The API reference defines a request with model, input and questions. For this router, the named question would be a choice called department, with billing, technical and other as the allowed values.

The corresponding application flow is straightforward:

This demo starts at step two. It deliberately does not include an untested SDK integration or disguise fixture answers as live model responses.

In a real service, I would also catch transport failures outside this function, apply a request deadline, and record model, question configuration version, selected queue and review reason. A successful HTTP request and a usable routing answer are different observations.

The 0.80 threshold is a teaching value. I would choose a real threshold with labeled validation data, then evaluate on a separate held-out set. I would count misroutes per queue, fallback frequency and the review workload the policy creates.

For the speed claim, I would compare the same task and evidence across endpoints, reporting end-to-end p50 and p95 latency alongside quality. A faster answer is valuable only if the downstream routing remains useful. No latency chart appears here because this build made no live calls.

I would also version the taxonomy. If technical support splits into product and infrastructure, that is a routing contract change. It deserves new fixtures and evaluation, even if the model stays the same.

My takeaway from the beta launch: a model can provide a compact judgment while the application keeps routing behavior explicit and testable.

I'm building Roster, AI employees that do real work. These are the boundaries I care about around their workflows: what answer did the model return, what did the software validate, and where did the work actually go?

Which narrow decision in your system is still hiding inside a generated paragraph?

── more in #ai-products 4 stories · sorted by recency
── more on @openai 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/openais-decisions-ap…] indexed:0 read:6min 2026-10-07 · —