Decision Models

A decision model answers typed questions about a piece of state. It does not generate text, so there is nothing to parse: you get a choice, a probability, or a score, with calibrated probabilities for each answer, in well under a second. Plugins ask a decision model through amp.ai.decide(...) and its single-question helpers.

Use a decision model for the small judgments a plugin makes constantly: is this safe, which route does this take, how urgent is this, what kind of work was this. The answer can still be wrong, so your plugin owns the thresholds and the control flow. When the answer needs reasoning or prose instead, use amp.ai.ask(...) or amp.ai.generate(...); see Use Thread-Scoped AI.

Amp itself uses the default decision model, Jev from TypeSafe, for jobs such as ordering the files in a diff and suggesting thread labels.

Ask for a Decision

amp.ai.decide(...) takes a state and 1–20 questions. Every question is answered against the same state in one round trip. Question names are yours; keep them short, because the answers come back under the same names and are typed from your request, so answers.destructive.noul and answers.scope.choice are checked against the questions you asked.

import type { PluginAPI } from '@ampcode/plugin'

export default function (amp: PluginAPI) {
	amp.on('tool.call', async (event, ctx) => {
		const command = amp.helpers.shellCommandFromToolCall(event)
		if (!command) return { action: 'allow' }

		const { answers, model } = await amp.ai.decide({
			state: command.command,
			questions: {
				destructive: {
					type: 'noul',
					instructions: 'Does this shell command delete data or rewrite git history?',
				},
				scope: {
					type: 'choice',
					instructions: 'What does the command mostly touch?',
					criteria: { files: null, git: null, network: null },
				},
			},
		})

		if (answers.destructive.noul > 0.9) {
			const message =
				`Blocked ${answers.scope.choice} command by ${model} ` +
				`(p=${answers.destructive.noul.toFixed(2)})`
			return { action: 'reject-and-continue', message }
		}
		return { action: 'allow' }
	})
}

state, instructions, and criteria each take text or JSON. JSON is passed to Jev as-is. Keep the state to the facts the questions need: text inside state can steer the answer, just as untrusted text can steer a generated one.

Question Types

TypeAsksAnswer
noulDoes this condition hold?noul, a probability from 0 to 1 that it holds
choiceWhich of these options fits?choice, probabilities for every option, and confidence
scoreWhere does this sit on an ordered scale?score, a probability-weighted level from 0 to the top level, legend, probabilities, and confidence

A noul question can describe what makes the condition true or false:

{
	type: 'noul',
	instructions: 'Is this change risky?',
	criteria: {
		true: 'It transforms production data or changes a public interface.',
		false: 'It is mechanical, generated, or covered by an existing test.',
	},
}

A choice question names 2–20 options. Each option maps to a description, or null when the name says enough. A score question lists 2–10 levels, lowest first:

{
	type: 'score',
	instructions: 'How urgent is this thread?',
	criteria: ['No deadline', 'This week', 'Today', 'Blocking someone now'],
}

For one question, the helpers return the answer directly. amp.ai.noul(...) returns a NoulAnswer, amp.ai.choice(...) a ChoiceAnswer, and amp.ai.score(...) a ScoreAnswer:

const { choice, confidence } = await amp.ai.choice({
	state: event.message,
	instructions: 'What kind of work did this turn mostly do?',
	criteria: { bugfix: null, feature: null, docs: null, other: null },
})

if (choice !== 'other' && confidence > 0.7) {
	await ctx.thread.addLabels([choice])
}

See Label the Thread for the full labeling example. Calls run through the current thread; outside a thread-bound handler, pass threadID in the same options object.

Select a Decision Model

Omit model to ask Jev. Pass model on amp.ai.decide(...) or on any of the single-question helpers to ask the other model instead:

// The default: TypeSafe's Jev.
const { noul } = await amp.ai.noul({
	state: command.command,
	instructions: 'Does this command delete data or rewrite git history?',
})

// The same question, answered by OpenAI's Decisions API.
const lunaAnswer = await amp.ai.noul({
	model: 'openai/gpt-6-luna',
	state: command.command,
	instructions: 'Does this command delete data or rewrite git history?',
})
ModelValueNotes
TypeSafe Jevtypesafe/jevThe default. Amp asks jev-latest; result.model reports the exact version that answered, such as jev-1.13.0.
OpenAI GPT-6 Lunaopenai/gpt-6-lunaOpenAI’s Decisions API. Billed like other OpenAI inference in the thread, through the same credential routing.

Choose the model in this order:

  1. Start with Jev. It is the default, and your thresholds only mean something for the model you tuned them on.
  2. Name openai/gpt-6-luna when the workspace allows only OpenAI, or when you have tuned and validated your thresholds on Luna and want that model to answer.
  3. Keep one model per decision. The two models answer the same questions but are calibrated differently, so a threshold tuned on one does not carry over to the other. Record result.model wherever you log a decision, so a model change is visible when you retune.

The model you name is the model that answers. Amp never falls through to the other model: if the named model is not configured, or the thread’s workspace has disabled its provider under Allowed Models, the call rejects and no state is sent to either provider. Handle the rejection where a decision is optional, and fail open or closed in your plugin, not by accident:

let destructive = 0
try {
	const { noul } = await amp.ai.noul({
		model: 'openai/gpt-6-luna',
		state: command.command,
		instructions: 'Does this command delete data or rewrite git history?',
	})
	destructive = noul
} catch {
	// No decision available: allow the call rather than block on a missing model.
	return { action: 'allow' }
}

Two OpenAI differences are worth knowing before you switch a question to Luna. JSON in state, instructions, and criteria is sent as its JSON text, and the true/false criteria of a noul question are appended to its instructions. The call also rejects if OpenAI declines to answer any one of the questions, where Jev returns an answer for each.

There is no workspace-wide or per-user setting that picks the decision model for plugins. The choice lives in the plugin’s request, thread by thread and call by call, so two plugins in the same thread can ask different models.

Limits

Part of the requestLimit
state50,000 characters of text
instructions and each criteria text10,000 characters each
Questions per call1–20, with names up to 100 characters
choice options2–20
score levels2–10, lowest first

A request outside these limits fails validation before it reaches a model. A decision is also not a guarantee: the typed shape is guaranteed, but the judgment can be wrong, and a probability is only useful against a threshold you chose. Test a new question against states where you know the right answer, including cases on both sides of your threshold, before you let it gate anything.

See the Plugin API reference for the full amp.ai types, including DecisionRequest, DecisionResult, and the three answer types.