HazelJS Decision Package
@hazeljs/decision is the native Decision Runtime for HazelJS Agent OS — bounded, typed, auditable judgment as a first-class primitive.
It is not a thin wrapper around Jev, OpenAI, Gemini, or another external model. The default provider is hazel-agent: an evidence → candidates → judge → critic → confidence pipeline built from HazelJS primitives. External providers are optional adapters; every result still passes policy and Gatekeeper.
Models propose. Agents evaluate. Critics challenge. Confidence informs. Policies govern. Skillgate classifies. Gatekeeper authorizes. Durable Kernel executes. Agent VM isolates effects. Agent OS observes.
Reasoning ≠ Decision ≠ Policy ≠ Authorization ≠ Execution. Confidence is evidence for policy routing, never permission to mutate the world. High confidence never bypasses Skillgate / Agent Gatekeeper.
Quick Reference
- Purpose: Bounded choice over a closed set (
choices: [...] as const) with risk routing, confidence, deterministic policy, Gatekeeper auth, durable HITL, and optional capability execution. - When to use: Incident remediation, refund paths, ticket routing, fraud/risk triage, “needs human?” gates — anywhere an agent must pick from a known set instead of free-form LLM JSON.
- Key APIs:
createDecisionRuntime,decide,resumeFromHuman,replay,getResult/getResultAsync,registerCapability,evaluate,DecisionLab/createDecisionLab,createGovernedDecisionRuntime,DecisionModule/DecisionService,HazelDecisionAgent,MockDecisionProvider,createOpenAiDecisionProvider/createGeminiDecisionProvider/createJevDecisionProvider/createLocalDecisionProvider,createEnsembleDecisionProvider,registerShadowProvider/runWithShadow,DecisionCache,DecisionHistory(trends,similarByEvidence),fitCalibrationFromHistory,projectDecisionFlow,createCostAwareRouter,hybridRules,@Decision. - Dependencies: Peer
@hazeljs/agent. Optional peers:@hazeljs/agent-gatekeeper,@hazeljs/skillgate,@hazeljs/audit,@hazeljs/ai,@hazeljs/core,@hazeljs/guardrails,@hazeljs/observability,@opentelemetry/api. - Common mistakes: Treating confidence as authorization; letting models invent choices outside the closed set; skipping Gatekeeper when
execute: true; auto-feeding history into prompts; assuming calibration authorizes; expecting Flow projection to replaceDecisionRuntime.
Installation
npm install @hazeljs/decision @hazeljs/agent
# recommended for production execution:
npm install @hazeljs/agent-gatekeeper @hazeljs/skillgate
# optional:
npm install @hazeljs/ai @hazeljs/audit @hazeljs/cli
Or:
hazel add decision
Minimal example
import { createDecisionRuntime } from '@hazeljs/decision';
const decisions = createDecisionRuntime();
const result = await decisions.decide({
objective: 'Choose the safest production remediation',
state: {
errorRate: 0.38,
previousErrorRate: 0.01,
deploymentAgeMinutes: 4,
failedHealthChecks: 8,
},
choices: ['retry', 'rollback', 'escalate', 'ignore'] as const,
});
// result.decision: 'retry' | 'rollback' | 'escalate' | 'ignore'
Architecture
APPLICATION
│
▼
decisions.decide({ objective, state, choices, risk, … })
│
▼
DECISION RUNTIME
│
▼
COMPLEXITY / RISK ROUTER
├── fast → judge
├── standard → evidence → judge → confidence
├── deliberate → evidence → candidates → judge → critic → confidence
├── human-required → … → HITL
└── rules → DNA / hybrid rules path
│
▼
DETERMINISTIC POLICY (allow | critique | review | deny | …)
│
▼
GATEKEEPER (authorization) ← only if execute: true
│
▼
DURABLE CHECKPOINTS + receipts (@hazeljs/agent)
│
▼
CAPABILITY HANDLER (optional; never without auth)
flowchart TD App[Application] --> Decide["decide()"] Decide --> Router[ComplexityRouter] Router --> Provider[DecisionProvider] Provider --> Conf[Confidence] Conf --> Policy[PolicyEvaluator] Policy -->|allow| GK[Gatekeeper] Policy -->|review| HITL[HumanTask] Policy -->|deny| Stop[Denied] GK -->|allow| Cap[CapabilityHandler] GK -->|deny| Stop
What this package reuses (does not reimplement)
| Concern | Primitive |
|---|---|
| Agent Runtime / DNA / HITL / checkpoints | @hazeljs/agent |
| Call-time authorization | @hazeljs/agent-gatekeeper |
| Skill risk class (floor only) | @hazeljs/skillgate |
| Effect isolation | Host effectGate / @hazeljs/agent-vm (documented, not duplicated) |
| Spans | Optional OTel tracer hazeljs |
Skillgate curates and classifies skills. Gatekeeper authorizes each invocation. The decision runtime never collapses those layers.
decide() request shape
await decisions.decide({
name: 'incident-remediation', // optional DNA / registry key
objective: 'Choose the safest remediation',
state: incident, // data — prompt-injection resistant
choices: ['retry', 'rollback', 'escalate', 'ignore'] as const,
risk: 'high', // or { level, impact, reversible }
strategy: 'auto', // fast | standard | deliberate | human-required | rules
provider: 'hazel-agent', // or mock / ensemble / openai / …
policy: { confidence: { high: 0.95, medium: 0.7 } },
execute: false, // true → Gatekeeper + capability after allow
cache: false, // opt-in; requires runtime cache
calibrate: false, // opt-in reporting remap
context: { tenantId: 'acme', agentId: 'incident-agent' },
});
Result (selected fields)
| Field | Meaning |
|---|---|
decision | Typed choice from choices |
confidence / confidenceDetail | Score + type (heuristic | ensemble | calibrated …); never auth |
evidence / candidates / critic | Pipeline artifacts |
strategy | Resolved execution strategy |
policy.outcome | allow | deny | critique | review | … |
execution | Whether capability was authorized / invoked |
hitl | Human task id / status when review required |
alternatives | Other candidates, ranked by score (up to 5) |
cost | modelCalls, token counts, optional estimatedCost |
latency | Per-stage milliseconds plus totalMs |
provenance.stages | Stage names for Lab / audit |
status | Run machine status (COMPLETED, WAITING_FOR_HUMAN, DENIED, …) |
Providers
| Provider | Role |
|---|---|
hazel-agent | Default. Native offline pipeline; optional LLM judge/critic via generateObject |
mock | Test double (MockDecisionProvider) |
auto | Router hook (defaults toward hazel-agent / cost profiles) |
ensemble | Majority / weighted / unanimous / risk-sensitive fan-out |
openai / gemini / jev / local | Thin adapters via create*DecisionProvider — still governed |
import {
createGovernedDecisionRuntime,
createOpenAiDecisionProvider,
createJevDecisionProvider,
} from '@hazeljs/decision';
const decisions = createGovernedDecisionRuntime({
generateObject: aiService, // @hazeljs/ai or duck-typed
agentRuntime,
auditService,
gatekeeper,
});
decisions.providers.register(createOpenAiDecisionProvider(aiService));
decisions.providers.register(createJevDecisionProvider({ decide: jevDecide }));
On model failure, hazel-agent falls back to the heuristic judge/critic (never to “allow”). Invalid choices outside the closed set are rejected.
Complexity / risk router
| Risk | Default strategy | Stages |
|---|---|---|
low | fast (small choice sets) | judge |
medium | standard | evidence → judge → confidence |
high | deliberate | + candidates + critic |
critical | human-required | + HITL |
Critical risk, and any DNA capability with execution.<choice>.hitl: true, resolve to human-required even if the request asks for fast, standard, or deliberate. strategy: 'rules' is selected first and is not rewritten.
Risk is trusted configuration. Models cannot lower it. Skillgate skill class may raise the risk floor only.
| Skill class | Risk floor |
|---|---|
read | low |
write | medium |
destructive | high |
admin | critical |
applySkillgateRiskFloor / skillClassToRiskFloor never lower the configured risk. Lookup is by capability name via lookupSkill.
Rules strategy and hybrid rules
Two different mechanisms. Both read numeric fields from state with dotted paths (amount, deployment.ageMinutes). Text in state cannot change them.
strategy: 'rules' asks hazel-agent to pick a closed-set choice from Decision DNA metadata.rules and skip the judge. A match sets confidence to 1 (type: 'heuristic') and records stage rules. The choice still passes policy and Gatekeeper.
await decisions.decide({
objective: 'Escalate after repeated attempts',
state: { attempts: 3 },
choices: ['retry', 'escalate'] as const,
strategy: 'rules',
provider: 'hazel-agent',
definition: {
objective: 'Escalate after repeated attempts',
choices: ['retry', 'escalate'],
metadata: {
rules: [{ whenPath: 'attempts', gte: 3, then: 'escalate' }],
},
},
});
then must be one of choices. No match continues with evidence extraction and the heuristic judge. The rules strategy does not call the LLM judge.
hybridRules on the runtime force a policy action after the provider has already chosen. They run before confidence bands. The first matching rule wins.
const decisions = createDecisionRuntime({
hybridRules: [
{ whenPath: 'amount', gte: 5000, then: 'review', reason: 'amount >= 5000' },
],
});
then is a policy action (allow | deny | critique | review | escalate | fallback), not a new choice. gte matches value >= n; gt matches value > n.
Confidence & policy
interface DecisionConfidence {
value: number; // [0, 1]
type: 'provider' | 'heuristic' | 'ensemble' | 'calibrated' | 'self-reported';
calibrated: boolean;
components?: {
candidateSeparation?: number;
criticAgreement?: number;
evidenceCoverage?: number;
providerConfidence?: number;
};
}
Default confidence bands (overridable via DNA / request policy):
| Band | Policy action |
|---|---|
≥ high (default 0.95) | allow (eligible for Gatekeeper) |
≥ medium (default 0.7) | critique |
| < medium | review (HITL) |
Even with confidence 0.999 and policy allow, Gatekeeper can deny. See package example high-confidence-denied.ts.
Custom policy rules
Pass policy as a rule list or as { rules, confidence }. Request rules replace DNA policy rules for that call. confidence.high / confidence.medium still set the default bands when no rule matches.
await decisions.decide({
...,
policy: {
confidence: { high: 0.9, medium: 0.6 },
rules: [
{ when: { risk: 'critical' }, action: 'review' },
{ when: { confidence: { lt: 0.4 } }, action: 'deny' },
],
},
});
when.confidence accepts gte, gt, lt, and lte. when.risk matches the trusted risk level.
| Policy action | What the runtime does |
|---|---|
allow | Eligible for Gatekeeper when execute: true and a capability is mapped |
critique | DECISION_FINALIZED — choice is returned, capability is not invoked |
review | WAITING_FOR_HUMAN + HumanTask |
escalate | Same HITL path as review |
deny | DENIED — execution is not requested |
fallback | COMPLETED — choice is returned, capability is not invoked |
strategy: 'human-required' forces review unless a hybrid rule already set an action. Hybrid rules override both bands and custom rules.
Decision DNA
Optional field on Agent DNA (format: 'hazeljs.agent.dna'):
exportAgentDna({
name: 'incident-agent',
tools: [],
decisions: {
'incident-remediation': {
version: '3',
objective: 'Choose the safest remediation',
choices: ['retry', 'rollback', 'escalate', 'ignore'],
risk: { level: 'high' },
confidence: { high: 0.95, medium: 0.7 },
evidence: { required: ['error-rate-change'], projections: [...] },
scoring: { rollback: [{ evidenceKey: 'error-rate-change', weight: 0.4 }] },
execution: {
rollback: { capability: 'deployment.rollback' },
},
},
},
});
Register with runtime.registry.register(...) or registry.registerFromDna(dna). DNA without decisions remains valid.
HITL & durable execution
Policy review / strategy human-required creates a HumanTask and checkpoints WAITING_FOR_HUMAN via @hazeljs/agent stores.
await decisions.resumeFromHuman({
decisionId,
runId,
action: 'override', // approve | reject | override
decision: 'escalate',
actor: 'ops-lead',
reason: 'Production rollback currently prohibited',
execute: true,
});
Overrides are audited. Resume loads the execution receipt and will not double-invoke the capability. Replay never re-executes side effects.
Durable reads
Checkpoints live on the Agent CheckpointService you pass to createDecisionRuntime({ checkpoints, humanTasks }). The runtime wraps them in DecisionDurableStore (kind: 'hazeljs.decision').
decisions.registerCapability('deployment.rollback', async ({ decisionId, state }) => {
return { rolledBack: true, decisionId };
});
const inMemory = decisions.getResult(result.id);
const hydrated = await decisions.getResultAsync(result.id, result.trace.runId);
const labRun = await decisions.replay(result.trace.runId!);
| Method | Behavior |
|---|---|
registerCapability(name, handler) | Same map as capabilityHandlers at construction |
getResult(decisionId) | In-memory result for this process |
getResultAsync(decisionId, runId) | Memory, then the newest checkpoint that carries that result |
replay(runId) | Latest checkpoint as a Lab run. Handlers are not called |
clearMemory() drops the in-memory index so tests can simulate a restart. The next getResultAsync / resumeFromHuman reads the checkpoint.
Decision Lab
import { createDecisionRuntime, createDecisionLab } from '@hazeljs/decision';
const lab = createDecisionLab(createDecisionRuntime());
const run = await lab.run({ objective, state, choices: [...] as const, risk: 'high' });
// run.graph — pipeline nodes for UI
const cmp = await lab.compare(request, ['hazel-agent', 'mock']);
// cmp.executionForbidden === true
Agent Office hosts the control plane at /office/decisions in the sibling app
hazeljs-agent-office (Run, Compare, Flow graph, History, Calibration) — not in Inspector.
Meridian exposes the teaching HTTP path: POST /api/decision/refund → resume with Gatekeeper still authorizing payments.refund.
Ensemble, cache, history, routing
import {
createEnsembleDecisionProvider,
DecisionCache,
DecisionHistory,
createCostAwareRouter,
} from '@hazeljs/decision';
runtime.providers.register(
createEnsembleDecisionProvider({
providers: [hazel, openai],
strategy: 'majority', // weighted | unanimous | risk-sensitive
})
);
const runtime = createDecisionRuntime({
cache: new DecisionCache({ ttlMs: 30_000 }),
history: new DecisionHistory(),
});
await runtime.decide({ ..., cache: true }); // opt-in only; never caches side effects
runtime.providers.setRouterHook(
createCostAwareRouter([
{ name: 'local', costTier: 'economy' },
{ name: 'hazel-agent', costTier: 'balanced' },
{ name: 'openai', costTier: 'premium', minRisk: 'high' },
])
);
history.trends(); // aggregates for Lab — never auto-injected into prompts
history.similarByEvidence(['error-rate-change', 'recent-deployment']);
similarByEvidence(keys, limit = 10) ranks stored rows by shared evidence keys (not raw state). Use it for Lab and incident lookup. It does not enter prompts.
Shadow compare
DecisionLab.compare already refuses execution. Two helpers wrap the same rule:
import { registerShadowProvider, runWithShadow, createDecisionLab } from '@hazeljs/decision';
registerShadowProvider(runtime.providers, createLocalDecisionProvider(generator));
const lab = createDecisionLab(runtime);
const { primary, shadows } = await runWithShadow(lab, request, ['local', 'mock']);
// both calls set execute: false
registerShadowProvider only registers the provider. Callers keep production decide({ execute: true }) off that name unless they opt in explicitly.
Flow graph projection
import { projectDecisionFlow, buildDecisionFlowDefinition } from '@hazeljs/decision';
const graph = projectDecisionFlow({ name: 'incident-remediation', risk: 'high' });
const stub = buildDecisionFlowDefinition(graph); // optional @hazeljs/flow registration
DecisionRuntime remains the authority. Flow projection is scaffolding / UI, not a second decision engine.
Calibration (opt-in)
import { fitCalibrationFromHistory, DecisionHistory } from '@hazeljs/decision';
const history = new DecisionHistory();
const runtime = createDecisionRuntime({ history });
// decide + evaluate({ expectedDecision }) …
runtime.setCalibration(fitCalibrationFromHistory(history));
await runtime.decide({ ..., calibrate: true }); // reporting only
Calibrated scores never authorize execution and are never auto-fed into prompts.
Module / DI
import { DecisionModule, DecisionService, createGovernedDecisionRuntime } from '@hazeljs/decision';
DecisionModule.forRoot({ generateObject, agentRuntime, gatekeeper, auditService });
// or without DI:
const decisions = createGovernedDecisionRuntime({ generateObject, gatekeeper });
DecisionService.decide(...) delegates to the wired DecisionRuntime.
Observability
In-process metrics include:
hazeljs_decisions_totalhazeljs_decision_durationhazeljs_decision_confidencehazeljs_decision_critic_totalhazeljs_decision_review_totalhazeljs_decision_override_totalhazeljs_decision_denied_totalhazeljs_decision_execution_totalhazeljs_decision_failures_total
Optional OTel span: decision.run (tracer hazeljs). Raw state is not recorded by default.
Security
- State is data (prompt-injection resistant).
- Choices are a closed set — invalid model output is rejected.
- Risk / policy / capabilities are not model-writable.
- Cross-tenant evidence is keyed by decision id +
tenantIdon provenance. - Replay never re-executes side effects.
- Missing Gatekeeper ⇒ execution denied when
execute: true. - History / calibration never auto-inject into prompts.
Errors
Thrown by the runtime today:
| Class | Code | When |
|---|---|---|
DecisionValidationError | DECISION_VALIDATION | Empty choices, missing objective, unknown DNA name, override without a decision |
DecisionInvalidOutputError | DECISION_INVALID_OUTPUT | Model or ensemble verb outside the closed set |
DecisionProviderError | DECISION_PROVIDER | Unknown provider, or a provider failure that is not a heuristic fallback |
DecisionReviewRequiredError | DECISION_REVIEW_REQUIRED | Only when metadata.throwOnReview is set and status is WAITING_FOR_HUMAN |
Default HITL returns the result (status: 'WAITING_FOR_HUMAN'). It does not throw. Gatekeeper deny is status: 'DENIED' on that result, not an authorization exception. A capability handler exception propagates as thrown.
DecisionTimeoutError, DecisionPolicyError, DecisionAuthorizationError, and DecisionExecutionError are exported (DECISION_TIMEOUT, DECISION_POLICY, DECISION_AUTHORIZATION, DECISION_EXECUTION) for callers. The current runtime does not raise them. All of these extend DecisionError (code, optional details).
CLI
run does not invoke a capability unless you pass --execute, and --execute still stops at Gatekeeper.
hazel decision run \
--name incident-remediation \
--objective "Choose the safest remediation" \
--state ./incident.json \
--choices retry,rollback,escalate,ignore \
--risk high \
--provider hazel-agent \
--strategy auto \
--json
hazel decision compare \
--providers hazel-agent,mock \
--state ./incident.json \
--risk high \
--json
hazel decision flow --name incident-remediation --risk critical --strategy auto --json
hazel decision lab --state ./incident.json --risk high --provider hazel-agent --json
| Command | Flags |
|---|---|
run | --name, --objective, --state, --choices, --risk, --provider, --strategy, --execute, --json |
compare | --name, --objective, --state, --choices, --risk, --providers, --json |
flow | --name, --risk, --strategy, --json |
lab | --state, --risk, --provider, --json |
Defaults: name incident-remediation, choices retry,rollback,escalate,ignore, risk high, provider hazel-agent, strategy auto. compare defaults providers to hazel-agent,mock and never executes.
Examples (package)
npx tsx examples/incident-remediation.ts
npx tsx examples/refund-hitl.ts
npx tsx examples/high-confidence-denied.ts
npx tsx examples/prompt-injection.ts
npx tsx examples/decision-lab.ts
npx tsx examples/flow-and-calibration.ts
npx tsx examples/ensemble.ts
Progressive complexity
decide()-
- provider
-
- risk + confidence
-
- critic + policy
-
- Gatekeeper + durable HITL
-
- Decision DNA + Lab + evaluation / calibration
Level 1 users are not forced into Level 6 concepts.
Related
- Guide: Decision Runtime
- Agent OS guide
- Skillgate — which APIs become skills
- Agent Gatekeeper — per-invocation authorize
- Agent package — DNA, HITL, durable runs
- Flow package — durable graphs (optional projection target)
Intentionally never
- Automatic prompt injection of historical outcomes
- Confidence as authorization
- Collapsing Skillgate + Gatekeeper into the decision model