DecisionNode is a decision engine: send it text, JSON or images with a question, and it answers with calibrated probabilities.
The official TypeScript and JavaScript client for the DecisionNode API: ask yes-or-no, pick-one, scale and number questions about any text, JSON or image, and get calibrated probabilities back as typed answers. Decide one request at a time, stream readings through a session, or run thousands as a batch at the batch price.
Runs on Node.js 20.16+, Deno, Bun and edge runtimes. Ships ESM, CommonJS and TypeScript declarations. Its one dependency is optional: ws, used only for sessions on Node 20, whose runtime has no WebSocket of its own.
npm install @decisionnode/sdkexport DECISIONNODE_API_KEY="your-api-key"A key's kind decides what it may call: Full access for your servers, Decisions only for a device or a service that only decides, Manage device keys for the server that hands out device keys, and Publishable for a web page or an app. See API keys.
import { DecisionNode, choice, number, score, truth } from '@decisionnode/sdk';
const dn = new DecisionNode(); // reads DECISIONNODE_API_KEY
const d = await dn.decide({
state: { ticket: 'I was charged twice and nobody has replied for 3 days.' },
questions: {
route: choice('Where should this go?', { billing: 'money', bug: 'broken', account: 'login' }),
urgency: score('How urgent is this?', ['routine', 'today', 'urgent', 'critical']),
escalate: truth('Escalate to a human now?'),
days: number('How many days has the customer waited?', 0, 30),
},
});
d.answers.route.choice; // "billing", typed as "billing" | "bug" | "account"
d.answers.route.probabilities; // { billing: 0.87, bug: 0.08, account: 0.05 }
d.answers.urgency.score; // 2.31
d.answers.escalate.truth; // 0.94
d.answers.days.number; // 3Answer types follow from the questions: d.answers.escalate is a TruthAnswer, d.answers.urgency a ScoreAnswer, d.answers.days a NumberAnswer, and d.answers.route.choice is the union of the option names. There are no thresholds in the API: what 0.94 means is your code's decision.
| Builder | Answer | Fields |
|---|---|---|
truth(instructions, ifTrue?, ifFalse?) |
TruthAnswer |
truth: the probability that the statement is true, 0 to 1 |
choice(instructions, options) |
ChoiceAnswer |
choice, confidence, probabilities by option name |
score(instructions, levels) |
ScoreAnswer |
score (expected level index), confidence, probabilities and legend by level index |
number(instructions, min?, max?, step?) |
NumberAnswer |
number (the most probable value), expected (the mean), confidence, probabilities by value |
point(instructions, image?, units?) |
PointAnswer |
x, y, present, confidence, image, width, height (see Points and boxes) |
box(instructions, image?, units?) |
BoxAnswer |
x1, y1, x2, y2, present, confidence, image, width, height |
options is a map of name to description, or a list of names: 1 to 255 of them. levels run low to high, 1 to 10 of them. state, instructions, descriptions and levels take a string or any JSON value.
A number question reads a value from a grid: min to max in steps of step, by default 0 to 255 in whole numbers, at most 4,096 values. A bound you leave out is left out of the request, so the API's default applies. Its instructions are required. probabilities is keyed by each value exactly as the API wrote it ("7", "3.5"); sort numerically when you need order.
const rating = await dn.number({ state: review, instructions: 'Rating out of 5, in halves?', min: 0, max: 5, step: 0.5 });
rating.number; // 3.5
rating.probabilities; // { "3": 0.21, "3.5": 0.612, "4": 0.13 }A plain object in the API's wire form works anywhere a builder does, so a request copied from the API reference runs unchanged:
await dn.decide({
state: 'Paid 740 SEK at Nordic Rail.',
questions: {
currency: { type: 'choice', instructions: 'Which currency?', criteria: { SEK: null, NOK: null, EUR: null } },
},
});Answers are read by question id. Question ids and option names are sent and returned byte for byte. Numbers are passed through exactly as the API sent them.
Send an image by URL when you can. It is the fastest input: nothing is uploaded, the request stays small, and the API fetches the image while it reads the rest of the request.
import { Image } from '@decisionnode/sdk';
const d = await dn.decide({
state: { claim: { total: '740.00', currency: 'SEK' } },
images: [Image.fromUrl('receipt', 'https://files.example.com/claims/4411/receipt.jpg')],
questions: { total_matches: truth('Does the total on the receipt equal the total in the claim?') },
});Local images work too, from a file, bytes or base64:
Image.fromPath('receipt', './receipt.jpg'); // read when the Image is made
Image.fromBytes('photo', bytes); // Uint8Array, ArrayBuffer or Blob
Image.fromBase64('photo', base64Text); // no data: prefix
Image.fromUploadId('photo', 'upl_...'); // an image uploaded beforeHow a local image travels is chosen for you, for the fastest answer:
- Small images go inline. When the local images of one request total under 512 KiB (
DEFAULT_INLINE_THRESHOLD, 524,288 bytes), they are sent as base64 in the request itself. - Larger ones are uploaded once. At or above that total, the client asks the API for upload URLs in one call, puts each image straight into storage in parallel, and names it in the request by its upload id. Only the storage's own headers go with an upload: never your key, never your custom headers.
- An uploaded image is reused. The client remembers each upload by a SHA-256 of its bytes, in memory, until 5 minutes before the upload expires (an upload lives an hour). The same bytes are then sent by their upload id whatever their size, across
decide,decideMany, session frames and batches, so they are never sent twice. Concurrent requests with the same bytes share one upload. - A stale upload is replaced. If the API reports an upload the client placed as expired, already used or missing, the client uploads the image again and resends the request once.
A request body may be at most 25 MiB (26,214,400 bytes). Inline images count toward it; uploaded images and URLs do not, so with the default threshold a request with images stays far below it. The client measures each body as it would be sent before sending it (an image it would upload counted by its upload id, so nothing is uploaded to measure) and throws RequestTooLargeError for one over the limit.
Set inlineThreshold on the client to move the cut-off: 0 uploads every local image, a large value sends everything inline. dn.upload(images) uploads 1 to 16 images whatever their size and returns their upload ids, for use later with Image.fromUploadId. No image is ever resized or re-encoded.
Every image is checked before anything is sent: ids present, at most 64 characters (counted as Unicode code points) and unique, URLs by the rules above that need no network, an upload id's media type one of the three, local bytes JPEG, PNG or WebP by their first bytes (a media type you give must match them), at most 10 MiB each, and at most 40 megapixels, read from the image header without decoding the image. A fault throws BadRequestError with the API's message, and no request is made. A request holds at most 16 images; a 17th throws ValidationError as the API answers it (a too_long entry on the images). An unreadable file throws a TypeError when the Image is made. Reading a path needs a runtime with a file system (Node, Deno, Bun); on edge runtimes pass bytes or a URL.
There are two ways to say which image a question is about:
- By id (recommended). Give each image a short, meaningful id and use it in the instructions, in backticks: "Does the total on
receiptmatch the amount in the claim?" - By number. Images are numbered from 1 in the order of
images: "image 2", "the second image", "Picture 2" and "the last image" all work.
The model sees each image as Picture 1 (receipt):, Picture 2 (claim_photo):, so both ways point to the same image. Prefer ids: they stay right when images are added or reordered. Use names, not digits, as ids (front, not 2). Videos work the same way, by id or by number ("video 1"), numbered separately in the order of videos. The image of a point or box question takes the id.
const d = await dn.decide({
state: 'Expense claim 4411: 740.00 EUR.',
images: [
Image.fromUrl('receipt', 'https://files.example.com/claims/4411/receipt.jpg'),
Image.fromUrl('claim_photo', 'https://files.example.com/claims/4411/photo.jpg'),
],
questions: { matches: truth('Does the total on `receipt` match the amount in the claim?') },
});A point question finds one spot in an image; a box question draws a rectangle around something. They mix freely with the other types in one request, and several of them on the same image share one read of it.
import { Image, box, point, truth } from '@decisionnode/sdk';
const d = await dn.decide({
images: [Image.fromUrl('belt', 'https://files.example.com/line-3/cam-2/0917.jpg')],
questions: {
pick: point('Point to the yellow package closest to the robot arm.'),
damage: box('Draw a box around the damaged package.', 'belt'),
any_damage: truth('Is any package on the belt damaged?'),
},
});
const pick = d.answers.pick; // a PointAnswer
if (pick.present > 0.8 && pick.confidence > 0.7) {
robot.pick(pick.x, pick.y);
} else {
sendToOperator();
}| Field | |
|---|---|
x, y |
The point, from the top-left corner of the image: x to the right, y down. Whole pixels by default; with units normalized, fractions of the width and height with 4 decimals |
x1, y1, x2, y2 |
The box: its top-left corner (x1, y1) and bottom-right corner (x2, y2), in the same units |
present |
The probability that what you asked for is in the image at all. Under a low value the place is only the best guess |
confidence |
The probability that the place is right, if the thing is there: for a point, that it lies on the thing; for a box, that it overlaps the true box by at least half (intersection over union of 0.5 or more) |
image, width, height |
The image the answer refers to and its size in pixels, so you can draw or convert the answer without another lookup |
Every field is always there. When several things match, the answer is the best match; say which one you mean in the instructions ("the leftmost yellow package").
A point or box question needs an image in the request. With two or more images, name the one to look in by its id (point(instructions, 'belt')); a reference by number ("Picture 2") goes in the instructions. These rules and the units ('pixels' or 'normalized') are checked before anything is sent, and a fault throws ValidationError as the API would. decide, decideMany and batch requests take point and box questions.
A video is sent as the frames you sampled, each with its time in seconds, and the questions see them as one video, in order and with their times. Only frames are sent: no video file, no video URL and no sound go to the API.
import { Video, VideoFrame, number, truth } from '@decisionnode/sdk';
// cameraFrames: [seconds, JPEG bytes] pairs you already hold
const frames = cameraFrames.map(([t, jpeg]) => new VideoFrame(t, jpeg));
const d = await dn.decide({
state: 'Warehouse camera 4, loading dock, shift B.',
videos: [new Video('dock', frames, { fps: 2 })],
questions: {
fall: truth('Does a person fall in the video?'),
forklifts: number('How many forklifts pass the door?', 0, 50),
},
});
d.answers.fall.truth;
d.usage.videoFrames;
d.warnings;new Video(id, frames, { fps, start, end }) |
id is unique across the request's images and videos. fps caps the frames read a second (above 0, at most 4; the API reads 2 by default). start and end set the window read, on the frames' own times; end is after start. Left out, the API's defaults apply |
new VideoFrame(t, data, mediaType?) |
t in seconds, at least 0, larger than the frame before it. data is JPEG, PNG or WebP bytes (Uint8Array or ArrayBuffer, the type read from them) or base64 text with its media type |
The ids, times, media types, fps and the window are checked before anything is sent, and a fault throws BadRequestError as the API would. A request holds at most 4 videos; a fifth throws ValidationError (a too_long entry on the videos). The frame limits are the API's and are published with it. Frames always go inline: they are never uploaded or resized. decide, the helpers, decideMany and batch requests take videos; sessions do not.
To turn a video file into frames, use the dn command-line tool (dn truth "Does a person fall?" --video dock.mp4 --fps 2); it runs FFmpeg on your machine and sends only the frames. This package has no such dependency.
A booster is a specialist model that reads the request's images before the decision. Its reading is added to what the model sees as evidence, and comes back in the response. The model still answers every question: a booster never answers one or adds one, and a request without boosters is answered exactly as before.
| Booster | Reads | Tells the model |
|---|---|---|
age |
A picture of a person | How old the person is, with a range and a confidence (the largest face is read) |
ai_generated |
Any picture | How likely the picture was made by an image generator, which generator most likely, and whether the file carries Content Credentials (C2PA) and who signed them |
face_detection |
Any picture | How many faces the picture shows and where, a box and a confidence for each |
face_recognition |
A picture of a person | Which faces match the collection the request names (see Collections): a box, a score and the matched item's reference for each |
ocr |
Any picture | The picture's text, whole, or what the booster's prompt asks for |
bynn_depth (Depth Vision) |
Any picture | How far away things are, in meters: the nearest obstacle straight ahead, seven directions from left to right, and a grid of the picture |
bynn_segment (Segment Vision) |
Any picture | Where the things you name in words are: each one's outline, box and centre, and with depth on, its distance and size in meters |
knowledge (Knowledge) |
The request's state and questions, never a picture | The passages of your own documents that apply to the request, as facts and rules to apply (see Knowledge) |
import { AgeReading, Image, truth } from '@decisionnode/sdk';
const d = await dn.decide({
images: [
Image.fromUrl('a1b2', 'https://files.example.com/a1b2.jpg'),
Image.fromUrl('c3d4', 'https://files.example.com/c3d4.jpg'),
],
boosters: ['age', 'ai_generated'],
questions: {
adult: truth('Is the person in the first picture an adult?'),
synthetic: truth('Is the second picture AI-generated?'),
},
});
d.answers.adult.truth; // 0.97
d.answers.synthetic.truth; // 0.91
const age = d.evidence?.age; // an Evidence: version 'age-v3', status 'ok', latencyMs, results
for (const reading of age?.results ?? []) {
if (reading instanceof AgeReading && reading.faces > 0) {
reading.ageYears; // 27, between reading.low and reading.high
}
}
d.evidence?.ai_generated?.results[1]; // an AiGeneratedReading: generated 0.93, method 'inference'
d.usage.boosterTokens; // the readings' share of usage.inputTokens
d.usage.boosters; // the booster calls billedboosters takes up to 4 booster names, each named once, in the order you give. A booster that needs a setting is a Booster. Here ai_generated reads only the second picture, and the request fails if it cannot:
import { Booster } from '@decisionnode/sdk';
await dn.decide({
images,
boosters: ['age', new Booster('ai_generated', { images: ['c3d4'], require: true })],
questions,
});new Booster(name, { images, require, options }) is sent as { name: 'ai_generated', images: ['c3d4'], require: true }, each setting only when given; a plain object in that form works too. A Booster with no setting is sent as its name, so the request stays the plain list:
| Setting | |
|---|---|
images |
The ids of the request's images the booster reads. Left out: every image |
require |
true: a booster that fails or does not answer in time fails the request (see the errors below). Left out or false: the decision runs without that reading, and evidence says why |
options |
The booster's own settings, for boosters that have any. The API checks them, not this package |
| Booster | options |
|---|---|
face_recognition |
collection (required): celebrities, or the id of one of your collections; threshold, 0 to 1 (0.5): a face scored below it is no match |
ocr |
prompt: the model's instruction, at most 2,000 characters; left out, all the visible text |
bynn_depth |
grid_size: the grid's cells per side, 2 to 16 (8) |
knowledge |
collection (required): a knowledge base's id; query, scope, match, filter, top_k, max_tokens, pinned, min_score, include_text. Use the knowledge() builder (see Knowledge) |
bynn_segment |
prompt (required): what to find, one phrase as a string or 1 to 4 phrases as an array, each at most 120 characters; max_instances, 1 to 16 (8); min_score, 0 to 1 (0.25); polygon_points, the points of each outline, 3 to 64 (24); depth, distances and sizes in meters (true) |
import { Booster, DepthReading, Image, SegmentReading, truth } from '@decisionnode/sdk';
const d = await dn.decide({
images: [Image.fromUrl('cam', 'https://files.example.com/drone/frame-0412.jpg')],
boosters: [
new Booster('bynn_depth', { options: { grid_size: 8 } }),
new Booster('bynn_segment', { options: { prompt: ['landing pad', 'person'] } }),
],
questions: { clear: truth('Is the landing pad clear of people?') },
});
const depth = d.evidence?.bynn_depth?.results[0];
if (depth instanceof DepthReading) {
depth.ahead; // 3.21 meters, or null when nothing is ahead
depth.directions[3]; // 2.81
}
const found = d.evidence?.bynn_segment?.results[0];
if (found instanceof SegmentReading) {
for (const it of found.instances) console.log(it.label, it.score, it.centroid, it.depth?.median);
}bynn_segment's prompt as a string is always one phrase; send several as an array. Descriptive phrases match better than short names: "purple octagonal frame on a pole" finds what "purple gate" may not. Each picture it reads is one booster call, its depth included. Depth Vision's and Segment Vision's distances are estimates from one picture, for decisions and planning, never a safety stop: a robot or a drone uses stereo cameras or LiDAR for that.
Boosters read images in every form (URL, inline or uploaded), and every question sees every reading. Which boosters exist and which your workspace may use is the API's to say, so nothing about boosters is checked before sending, and a name the API adds later works with this version. decide, the helpers, decideMany and batch requests take boosters; sessions do not. Without boosters (left out or an empty list) the request carries no boosters field.
decision.evidence holds one Evidence per booster the request named, by name in the request's order, and is null for a request that named none:
| Field | |
|---|---|
version |
The version of the booster that read, such as age-v3 |
status |
ok; timeout (no answer in time); error; skipped (none of the request's images is one it reads) |
latencyMs |
How long the booster took |
billedCalls |
The calls of this booster billed on this request |
results |
One reading per image the booster was to read, each naming its image. A picture it did not read (it timed out or failed) or could not decode (readable false) is an UnknownReading holding those fields, and status says why |
raw |
The entry as the API sent it |
| Reading | Fields |
|---|---|
AgeReading |
image, faces (how many faces were found; the largest is read), and ageYears, low, high (the range) and confidence, each null when faces is 0 |
AiGeneratedReading |
image, generated (the probability that a generator made the picture), confidence, method (inference: read from the pixels; c2pa: the file's trusted Content Credentials declare it generated or edited by AI), topGenerator (name and probability of the likeliest generator when the reading says likely generated; null otherwise, and always on a c2pa reading), c2paSigner (who signed the file's Content Credentials when they are trusted, else null), c2pa (untrusted when the file carries Content Credentials that validate but are not trusted, which change nothing in the reading; else null) |
FaceDetectionReading |
image, faces (how many faces the picture shows; 0 is a reading), detections (at most 64, each box, the corners x1, y1, x2, y2 in the request's units, and confidence) |
FaceRecognitionReading |
image, faces (how many faces were searched), matches (best first, each box, score, collection, reference, name for a public figure, label and metadata for your own item, collectionName), collectionName (your collection's name) |
OcrReading |
image, text (the reading; empty when the picture shows no text), truncated (true when the reading was cut at the model's context limit) |
DepthReading |
image, ahead (meters to the nearest obstacle straight ahead, the floor left out; null when none is found), directions (7 entries, left to right, each meters or null), ground (whether a floor or ground was found and left out), gridRows, gridCols, grid (rows of meters from the top left), depthMin, depthMax, depthMedian, width, height (the picture's size in pixels) |
KnowledgeReading |
One per request, not per picture: collection, collectionName, corpus, query, budget, scope, warnings, passages (each a KnowledgePassage), omitted, pendingItems, search, truncated (see Knowledge) |
SegmentReading |
image, width, height, prompts (the phrases searched for), instances (best first, each a SegmentInstance: label, score, box [x1, y1, x2, y2], polygon [[x, y], ...], area, centroid [x, y], opening (the hole of a gate, a doorway or a ring: polygon, centroid, width, height, offset, area; or null), depth (median, openingMedian, openingRimMedian) and sizeM (meters: width, height, opening), both null with depth off), depthSource, assumedHfovDeg. Coordinates are fractions of the picture from its top left |
UnknownReading |
image and raw: a reading of a booster this version does not know, or one with a field missing or of the wrong type. The decision does not fail |
A request whose boosters name ocr waits 30 s per attempt by default instead of 10 s. A timeout you set on the client is used as given, even below 30 s. Such a decision is never retried after a timeout, because the server finishes the timed-out attempt and may bill it: set a timeout of at least 30 s for requests that name ocr. Its other retries (408, 429, 5xx, a lost connection) stay.
Each reading also has raw. Numbers are passed through as received. usage.boosterTokens (the readings' share of inputTokens) and usage.boosters (the booster calls billed) are null for a request without boosters.
Booster calls are drawn from your prepaid balance with the request's tokens: one call per booster and image read with status ok. A booster that timed out or failed is free, and a refused request pays for none of its boosters. The readings' tokens are input tokens at the model's price. The price per call is on the pricing page.
Status, errorType |
Error | When |
|---|---|---|
400 api_usage_error |
BadRequestError, the reason in reason |
boosters is malformed: not a list, more than 4, a booster named twice, an unknown key in options |
400 booster_unknown |
BadRequestError |
A name that is not a booster |
400 booster_input_mismatch |
BadRequestError |
A booster that reads images on a request without any, or an images id that is not one of the request's images |
403 booster_not_enabled |
PermissionDeniedError |
The booster is turned off for your workspace (in the console) |
529 booster_unavailable |
OverloadedError |
A booster with require: true failed or did not answer in time. Retried like every 529 |
Every error of a request that named boosters carries boosters, each booster's status and time by name, read from the x-decisionnode-boosters response header, so you can see which required booster failed:
try {
await dn.decide({ images, questions, boosters: [new Booster('age', { require: true })] });
} catch (err) {
if (err instanceof OverloadedError && err.errorType === 'booster_unavailable') {
err.boosters.age?.status; // 'timeout' or 'error'
}
}const dn = new DecisionNode({
apiKey: 'your-api-key', // default: DECISIONNODE_API_KEY
baseUrl: 'https://...', // default: DECISIONNODE_BASE_URL, else https://api.decisionnode.com
model: 'decisionnode-moe', // default: DECISIONNODE_MODEL, else decisionnode-moe
timeout: 10_000, // milliseconds per attempt, above 0
maxRetries: 2, // retries after the first attempt, 0 or more
inlineThreshold: 524_288, // bytes; local images under it go inline, see Images
uploadTimeout: 60_000, // milliseconds per storage PUT of an upload, apart from timeout
headers: { 'x-team': 'support' },
fetch: customFetch, // default: the global fetch
fetchHonoursManualRedirect: true, // required with fetch, see below
});Every option is optional. An argument wins over the environment, which wins over the default; an empty string counts as not given, so apiKey: '' still reads DECISIONNODE_API_KEY. A client is safe to share and reuses its connections; create one and keep it.
The base URL must use https; http is accepted only for localhost, 127.0.0.1 and ::1, so a key never travels unencrypted. Any other base URL, a timeout of 0 or less, or a maxRetries or inlineThreshold that is not a whole number 0 or more throws a TypeError or RangeError when the client is made, before any request.
| Method | Returns |
|---|---|
decide({ state, questions, images, model }) |
Decision |
decideMany(requests, { concurrency }) |
DecisionResult[]: one Decision or error per request, in input order |
check({ state, instructions, ifTrue, ifFalse, images, model }) |
TruthAnswer |
classify({ state, instructions, options, images, model }) |
ChoiceAnswer |
score({ state, instructions, levels, images, model }) |
ScoreAnswer |
number({ state, instructions, min, max, step, images, model }) |
NumberAnswer |
gate({ action, context, images, model }) |
Decision with risk, touches_production and verdict |
models() |
ModelInfo[] with each model's published limits |
upload(images) |
The upload id of each image |
checkRequest(method, request) |
Nothing: runs a call's local checks without sending |
openSession({ questions, state, instructions, model, window, ttlSeconds }) |
Session |
supervise(body, feed, onReply) |
The last SessionClosed, or null |
batches.create, add, finalize, get, list, results, cancel |
Batch, BatchPage, BatchResults |
runBatch(requests, { pollInterval, timeout }) |
{ batch, results } |
decideRaw(body) |
The response body as parsed JSON |
An answer of a type this version does not know yet is an UnknownAnswer with every field in raw, and so is an answer of a known type with a field missing or of the wrong type: the decision does not fail. check, classify, score and number promise a typed answer instead, so when their own answer is missing or malformed they throw a ServerError with the status, requestId and the server's reason from x-decisionnode-error. Every method takes a last argument { signal } to cancel the call with an AbortSignal.
A Decision has answers, model (the version that answered; for a fine-tuned model its pinned name), usage.inputTokens, usage.outputTokens, for a request with videos usage.videoFrames and usage.videoSeconds (null otherwise), for a request with boosters evidence, usage.boosterTokens and usage.boosters (null otherwise), usage.cost (what the answer cost in USD: usd, modelUsd, boostersUsd and freeTier, read as sent and never summed by the SDK; null when the server sent none), fallback (on a fallback answer the model that answered and the one asked for, null otherwise; see Fallback below), warnings (what the API changed when it read the videos, an empty array when nothing), requestId, latencyMs (the server's time), safety and raw (the parsed body). A token count or latency the response did not carry is null, never 0. decision.get('route') reads one answer by id.
The client checks every request against every fixed rule the API documents before it sends anything, so a request the API would refuse costs no round trip and the server never sees it. A fault throws exactly what the API would answer: the same class, the same errorType and message, and for a 422 a ValidationError whose entries are the API's own list. These errors have no status and no requestId, since no request was made.
| Rule | Thrown as |
|---|---|
A question that is not an object, has no type, or has a field of the wrong type (instructions, a state, an option description, a score level or a truth side that is not a string, an object or an array; a choice without options as an object; a score without levels as a list) |
ValidationError, the API's entries |
| An unknown question type | BadRequestError, api_usage_error, "Invalid request." |
| A choice of no options or more than 255, a score of more than 10 levels, a truth with neither instructions nor criteria, a number without instructions | BadRequestError with the API's plain message and no errorType |
A number grid: min, max or step not a finite number, beyond 2^53, step not above 0, more than 6 decimals in min or step, beyond the bound that keeps every value exact, min above max, more than 4,096 values |
ValidationError, the API's entries |
| More than 128 objects and arrays inside each other | BadRequestError, api_usage_error |
Images (see Images); an image URL that is not https |
BadRequestError, api_usage_error |
An image id over 64 characters, counted as Unicode code points (Image 1: its id is longer than 64 characters., images counted from 0) |
BadRequestError, api_usage_error |
An https image URL over 2,048 characters, with user information, on a port other than 443, or naming a private, loopback, link-local or other reserved address literally |
BadRequestError, image_url_refused |
A session's window outside 1 to 64 or ttl_seconds outside 1 to 3,600 |
ValidationError, the API's entries |
| A session frame (see Sessions) | BadRequestError, api_usage_error |
A batch request without a customId, an empty or repeated one, an add with no requests, a batches.list offset below 0 or limit outside 1 to 100 |
BadRequestError, api_usage_error |
A body over 25 MiB (26,214,400 bytes) as it would be sent: a request, each decideMany request (none is sent when one is over), each batch line with its customId |
RequestTooLargeError, request_too_large_error |
Some limits are stated only by /v1/models: whether a model answers number questions or serves sessions, its largest session window, how many requests a batch holds and how long a customId may be. Once you have called models() on a client, it checks those against what it read, and a value /v1/models restates (such as the largest window) replaces the documented one; before that, those checks are left to the API. A request's model is matched to an entry by its name, else by the entry that states that name as its alias or its pinned version, so decisionnode-moe finds the limits of the version it stands for; a name the list does not state is left to the API. Token counts and anything that depends on the server's state, such as whether an upload is still live, stay the API's to judge. decideRaw sends its body exactly as given, unchecked.
To check a request without sending it, for example to validate input from a form before you queue it, pass the same arguments to checkRequest. It runs every check the call would run, with the limits held from models(), and throws the same error; it sends nothing and uploads nothing, and resolves when the request would be sent:
await dn.checkRequest('decide', { state, questions, images });
await dn.checkRequest('openSession', { questions, window: 8 });
await dn.checkRequest('batches.create', requests); // or 'batches.add'
await session.checkFrame({ frame: reading, questions: ['abort'] });A fetch you pass must honour redirect: 'manual': it must never follow a redirect, and it must keep TLS certificate and hostname checks on. A function cannot be inspected, so the client cannot show that yours does; it refuses a fetch with a TypeError when the client is made unless you confirm it with fetchHonoursManualRedirect: true. The global fetch of Node, Deno, Bun and browsers honours it. Even with the confirmation, your fetch is called with redirect: 'manual', and a response it reports as redirected, as an opaque redirect, or from a URL other than the one requested is refused with a DecisionNodeError, its body never read.
A fetch that wraps the global one, as most do, is safe as long as it passes init.redirect through and does not set its own:
const dn = new DecisionNode({
fetch: (url, init) => fetch(url, { ...init, keepalive: true }),
fetchHonoursManualRedirect: true,
});The process's own transport settings count too: with NODE_TLS_REJECT_UNAUTHORIZED=0 (which turns off certificate checks in Node and Bun), an https base URL is refused with a TypeError, both when the client is made and before each request, so your key never goes over a connection whose certificate is unchecked. The scheme is read case-insensitively, so HTTPS:// counts as https. Only that variable can be seen: a process-wide dispatcher you install that turns off certificate checks cannot be detected, and keeping checks on there is your responsibility.
const results = await dn.decideMany(
tickets.map((ticket) => ({ state: ticket, questions: { spam: truth('Is this spam?') } })),
{ concurrency: 8 },
);
for (const r of results) {
if (r instanceof Error) console.error(r.message);
else console.log(r.answers.spam.truth);
}At most concurrency requests are in flight (default 8). Every request is checked before any is sent: a concurrency below 1 throws a RangeError, a malformed request (an empty question map, a value that cannot be serialised as JSON) throws a TypeError, and a request the API would refuse (see "Checked before sending") throws its error, with nothing sent. Once sending starts, one failure does not stop the others. Requests that carry the same image share one upload.
const g = await dn.gate({ action: 'DROP TABLE invoices;', context: { environment: 'production' } });
g.answers.verdict.choice; // "allow" | "confirm" | "block"
g.answers.risk.score; // 0 (none) to 4 (severe)The gate's wording is fixed and the same in every DecisionNode SDK:
| Id | Type | Question |
|---|---|---|
risk |
score | How much harm could this action cause if it is wrong? Levels none, low, moderate, high, severe. |
touches_production |
truth | Does this action change a production system, production data or anything customers use? |
verdict |
choice | Should this action run as it is, wait for a person to confirm it, or be blocked? Options allow (safe to run without asking), confirm (a person should confirm it first), block (should not run). |
The state sent is { action, context }.
const body = await dn.decideRaw({
model: 'decisionnode-moe',
state: 'Customer: refund me.',
questions: { refund: { type: 'truth', instructions: 'Is this a refund request?' } },
});decideRaw sends the body exactly as given and returns the parsed response.
A session answers a stream of readings against a fixed context: open it once with the questions, then send frames over one WebSocket and read a reply per frame. When frames arrive faster than they are answered, the newest one wins: the frames it replaced are listed in its reply's dropped.
const session = await dn.openSession({
instructions: 'You are the mission supervisor of a survey drone.',
state: { mission: 'Survey the north field' },
questions: {
mode: choice('Which flight mode now?', { continue: 'keep flying', hold: 'hover', return: 'fly home', land: 'land now' }),
abort: truth('Should the mission be aborted now?'),
},
});
await session.connect();
await session.send({ frame: { battery: 0.31, wind_mps: 11.4 } });
await session.send({ frame: 'Obstacle ahead.', image: Image.fromPath('camera', './frame.jpg') });
for await (const event of session.replies()) {
if (event instanceof Reply) console.log(event.seq, event.answers.mode);
else if (event instanceof FrameError) console.warn(event.seq, event.errorType, event.message);
else break; // SessionClosed: event.code, event.reopen
}
await session.end();openSessionalways sends a model. It is retried like a decision, except when the key already holds as many open sessions as it may: that429throwsSessionLimitError(aRateLimitError) at once.connect()sends your key in the handshake'sAuthorizationheader, never in the URL, together with the client's custom headers. A refused handshake rejects withSessionClosed. After a close with code1013(the server was busy), callconnect()again: the session is kept.send()numbers frames from 1 unless you passseq, and resolves once the frame is on the socket, without waiting for its reply. A frame that breaks a fixed rule (noframeand noimage, an emptyframe, an empty or unknownquestionslist, aseqout of range, an image URL, an image that breaks the image rules) is refused bysend()itself with aBadRequestError: nothing is written and it takes noseq. Frames go out in the order you send them. A frame's image follows the rules under Images: small ones inline, large or already uploaded ones by upload id. Frames take no image URLs.checkFrame()runs the same checks assend()without writing anything.replies()yieldsReplyandFrameErrorvalues and ends with theSessionClosed. A frame error is a value, never thrown: the session goes on.next({ timeout })reads one event.end()ends the session and closes the socket; a session that has already ended counts as ended. Pings are answered automatically.
SessionClosed.reopen is true for the codes after which a new session can be opened with the same body (1001, 1006, 1011, 4404, 4408, 4409, 4410). supervise does that for you over one long-lived feed:
async function* readings() {
for await (const telemetry of drone) yield { frame: telemetry };
}
const closed = await dn.supervise(body, readings(), (event) => console.log(event));It opens and connects a session, sends each reading, hands every reply and frame error to your callback, and reopens when a session ends with a reopen code: at once after 4408 (idle) and 4410 (expired), after a backoff of 1 to 30 seconds with jitter after the others, and on 1013 it connects to the same session again. It stops on any other code and returns that SessionClosed. When the feed ends it waits for the replies still owed, ends the session, and returns the last SessionClosed, or null.
Sessions need a WebSocket that can send an Authorization header: Node 22 and later, Deno and Bun have one, and on Node 20 the client uses the ws package, installed with it as an optional dependency. Browsers cannot send that header, so connect() throws a TypeError there; open sessions from your server.
The session's socket goes through the same proxy and trusts the same certificates as the client's requests wherever the runtime allows it. Options for the socket alone go in the client's webSocketOptions; it may not turn certificate checks off.
| Runtime | Proxy | Custom certificate authority |
|---|---|---|
| Node 22 and later | The process's global dispatcher, which fetch uses too: set an undici ProxyAgent with setGlobalDispatcher and both follow it. webSocketOptions: { dispatcher } sets one for the socket alone |
NODE_EXTRA_CA_CERTS, or the connect.ca of the dispatcher |
Node 20 (ws) |
webSocketOptions: { agent }, an HTTP agent that tunnels through your proxy; the global dispatcher does not reach ws |
NODE_EXTRA_CA_CERTS, or webSocketOptions: { ca } |
| Bun | HTTPS_PROXY, HTTP_PROXY and NO_PROXY, which Bun's fetch reads, are handed to the socket too; webSocketOptions: { proxy } sets one for the socket alone. Needs a Bun version whose WebSocket takes proxy |
webSocketOptions: { tls: { ca } } |
| Deno | HTTPS_PROXY, HTTP_PROXY and NO_PROXY apply to the socket as to fetch |
--cert or DENO_CERT, as for fetch |
When you pass your own fetch, the client cannot see what proxy it uses: configure the socket's with the global settings above or webSocketOptions.
A batch runs many decide requests at the batch price: 50% off the live price on decisionnode-flash, 30% off on decisionnode-moe. The batch price is each model's, its batch_price_factor in models() (0.5 and 0.7; a fine-tuned model's entry states none), never the batch's, and each line's charge is in its usage. There is no set time at which a batch runs or finishes, so poll for it. Requests not answered within 24 hours of finalizing expire and are not charged. Each request carries your own customId, unique in the batch.
const { batch, results } = await dn.runBatch(
tickets.map((t) => ({ customId: t.id, state: t.text, questions: { route: choice('Where?', ['billing', 'bug']) } })),
{ pollInterval: 60_000 },
);
for await (const r of results) {
if (r.decision) console.log(r.customId, r.decision.answers.route.choice);
else console.warn(r.customId, r.status, r.error?.message);
}runBatch creates an empty draft, adds the requests, finalizes the batch, checks on it every pollInterval milliseconds (at least 10,000, default 60,000; a longer Retry-After from the API wins) until it ends, and returns it with its results. Past timeout (default 26 hours) it throws TimeoutError. If adding the requests fails, the draft is cancelled and the error thrown.
The steps are also available one by one on dn.batches:
| Method | Does |
|---|---|
create(requests?) |
Creates a batch; with no requests, an empty draft |
add(batchId, requests) |
Adds requests to a draft, in calls of at most 10 MiB |
finalize(batchId) |
Queues the draft to run |
get(batchId) |
The batch, with its state and counters |
list({ offset, limit }) |
A page of your batches, newest first, with nextOffset |
results(batchId) |
The results of an ended batch, read line by line as they download |
cancel(batchId) |
Cancels the batch; lines already running finish |
Requests are sent as JSON Lines; each line always carries a model (the request's, else the client's), and a missing state is sent as empty. Every request is checked before anything is sent, as for decide, and its local images go inline or by upload id by the same threshold, counted per line. A Batch has every field of the API's batch object: id, state, counters, createdAt, finalizedAt, expiresAt, endedAt, resultsUrl, resultsExpireAt, usage, error, and raw.
results() returns BatchResults: iterate it once with for await, or call toArray(). It reads the whole results file as it downloads, line by line, however many lines it has; nothing is paged and the file is never held whole in memory unless you collect it. Each BatchResult has customId, status (the line's HTTP status, or expired or cancelled for a line that never ran), headers, and either decision (a Decision for a 200 line, numbers exactly as sent) or error (the error that status and body give, as a value, never thrown).
Creating, adding and finalizing are retried only when the API answers 429 or 529, since it applied nothing then; never after a timeout or a lost answer, which could create a second draft or add lines twice. A finalize answered 409 because an earlier, lost attempt went through returns the batch.
A collection holds faces your workspace enrolls, which the face_recognition booster searches. client.collections creates, reads and deletes collections and their items; every call returns the API's object as sent.
const staff = await dn.collections.create({ name: 'Staff' });
const item = await dn.collections.enroll(staff.id, {
image: Image.fromUrl('badge', 'https://files.example.com/badges/emp-1042.jpg'),
reference: 'emp-1042',
label: 'Reception',
metadata: { department: 'Reception', badge_level: 2, visitor: false },
});
await dn.collections.updateItem(staff.id, item.id, { label: null, metadata: { department: 'Front desk' } });
const page = await dn.collections.listItems(staff.id, { reference_prefix: 'emp-' });| Call | Sends |
|---|---|
create({ name, type, scopeKey }) |
POST /v1/collections; type is face, the default, or text for a knowledge base (see Knowledge) |
list(), get(id), delete(id) |
the collections, one collection, its deletion (a knowledge base still purging answers { id, status: 'purging' }, also a success) |
enroll(id, { image, reference, label, metadata }) |
one face from one image, under your reference; the image is sent as a decision sends it |
updateItem(id, itemId, { reference, label, metadata }) |
PATCH with only the fields given: label: null clears the label, and metadata replaces the whole object ({} empties it) |
listItems(id, { offset, limit, reference, reference_prefix }) |
one page of items, filtered by an exact reference or a reference_prefix (not both); the page carries total and total_capped |
deleteItem(id, itemId) |
the item's deletion |
create, enroll and updateItem send an Idempotency-Key, a new UUID per call and the same on every attempt of it, so the API does the work and bills it once however often the call is tried. They retry like any call, and also on a 409 idempotency_in_progress after its Retry-After; a 409 idempotency_key_reused is thrown and never retried.
Checked before sending, as the API checks them: a reference of 1 to 64 of A-Z a-z 0-9 _ . : -, a label of 1 to 128 characters, and metadata as an object of at most 20 keys, each key 1 to 40 of a-z, 0-9 and _, each value a text of at most 500 characters, a number or true or false. Of an enrolled image only a URL is checked here; every other fault of the picture is the API's 422 image_unreadable.
A knowledge base is a collection of type text that holds your own documents: a claims policy, house rules, a mission's special instructions. The knowledge booster finds the passages that apply to a decision and gives them to the model as facts and rules to apply, and the decision cites them.
import { DecisionNode, Document } from '@decisionnode/sdk';
const dn = new DecisionNode();
const kb = await dn.collections.create({ name: 'Claims policy', type: 'text', scopeKey: 'mission' });
// Markdown, inline: a string is a Markdown document.
await dn.collections.addDocument(kb.id, {
document: '# Receipts\n\nReject any claim over 500 SEK without a receipt.',
title: 'Receipts rule',
reference: 'receipts-rule',
pinned: true,
});
// A PDF from a path: uploaded, then converted to text.
const policy = await dn.collections.addDocument(kb.id, {
document: Document.fromPath('./claims-policy-2026.pdf'),
reference: 'policy-2026',
});
// Ingestion is asynchronous: wait until the document is ready (or failed).
const ready = await dn.collections.waitUntilReady(kb.id, { itemId: policy.id });
if (ready.status === 'failed') console.error(ready.error);A document is Document.fromText(text, format) (markdown, the default, or text), Document.fromPath(path), Document.fromBytes(data) (a Uint8Array, an ArrayBuffer or a Blob), Document.fromStream(stream) (a web ReadableStream or a Node Readable, read to its end before anything is sent) or Document.fromUploadId(id); a plain string is Markdown. A file's type comes from contentType, else its name's extension, else its first bytes; DOCX, PPTX, XLSX and text need a name or a contentType. A file's name is a path's base name unless you give filename, and its stem is the default title.
Text and Markdown files under the client's inlineThreshold (512 KiB) that are UTF-8 are sent inline, in one call. Every other file is uploaded first and then named by its upload id. A file is converted to text, and once its text is ready only the text is kept: the original file is deleted. A document comes back queued, then processing (stage extracting for a converted file, chunking, embedding), then ready or failed (its error says why).
wait: trueonaddDocumentorreplaceDocumentasks the server to hold the call up to 10 seconds and answer once the document is ready, else as it stands. Such a call waits 20 seconds per attempt unless you set the client'stimeout.waitUntilReady(id, { itemId, timeout, pollInterval })reads the document until it isreadyorfailedand returns it; withoutitemIdit reads the knowledge base until nothing isqueuedorprocessingand returns it. It reads at once, then waitspollInterval(1 second), 1.5 times longer after each read up to 10 seconds, and throwsTimeoutErroroncetimeout(5 minutes, counted from the call) has passed. A failed document is returned, not thrown.
A pinned document is given to every request that names the knowledge base, whatever the request asks: pin a rule that must always apply. pinRank (0 to 1,000) orders the pins, lowest first.
The scope key (mission above) is a metadata key whose value scopes a document. A document with metadata: { mission: 'M-42' } is seen only by a request that names scope: 'M-42'; a document without it is global and seen by every request. A request without scope sees the global documents only, with the warning scope_not_named.
await dn.collections.addDocument(kb.id, {
document: '# Special instructions\n\nPhotos of damage taken after 2026-09-01 count as current.',
title: 'Mission M-42 brief',
metadata: { mission: 'M-42' },
});import { KnowledgeReading, knowledge, truth } from '@decisionnode/sdk';
const d = await dn.decide({
state: { claim: { amount: '740.00', receipt: false }, mission: 'M-42' },
boosters: [knowledge(kb.id, { scope: 'M-42', require: true })],
questions: { approve: truth('Should the claim be approved under our policy?') },
});
const reading = d.evidence?.knowledge?.results[0];
if (reading instanceof KnowledgeReading) {
for (const p of reading.passages) {
console.log(p.n, p.pinned, p.title, p.heading, p.score); // score is null for a pin
}
}knowledge(collection, { query, scope, match, filter, topK, maxTokens, pinned, minScore, includeText, require }) is sent as { name: 'knowledge', require: true, options: { collection, ... } }, each option only when given; the API checks them. topK (0 to 20, 4) is the most retrieved passages, pins not counted, and 0 gives the pins only. maxTokens is 'auto' or 128 to 8,192. match ({ region: 'SE' }) and filter (a tree of and, or and conditions) narrow by metadata, never by the scope key. includeText adds each passage's text and needs a full-scope key.
A passage cites its document: item (the document's id), reference, title, heading, chunk, and byteStart and byteEnd, UTF-8 byte offsets (end exclusive) into the document's stored text. Read that text with getItem(id, itemId, { includeText: true }), and encode it to UTF-8 before slicing by the offsets, since JavaScript strings count UTF-16 units:
const doc = await dn.collections.getItem(kb.id, p.item, { includeText: true });
const bytes = new TextEncoder().encode(doc.text ?? '');
const quoted = new TextDecoder().decode(bytes.subarray(p.byteStart, p.byteEnd));KnowledgeReading also has collectionName, corpus.version, query (source, tokens), budget (mode, tokens, entry, cause), scope (key, value), warnings (scope_not_named, over_budget, budget_floor, pinned_filtered), omitted (passages left out, by cause), pendingItems, search and truncated, each as sent. A publishable key's passages carry ids and offsets only: collectionName, title, heading and reference are null. A reading with a required field missing or of the wrong type is an UnknownReading, and the decision does not fail.
require: true guarantees that the search ran over a ready knowledge base and that every pin in scope reached the model. Without it, a reading that could not run leaves the decision to go ahead without passages, and evidence says why.
const found = await dn.collections.search(kb.id, { query: 'receipts for claims over 500 SEK', scope: 'M-42', topK: 3 });
for (const p of found.passages) console.log(p.title, p.score, p.text);search takes query, scope, match, filter, topK, pinned and maxTokens, and returns a KnowledgeReading with every passage's text. It is billed as one knowledge call and needs a full-scope key.
| Call | Sends |
|---|---|
addDocument(id, { document, title, reference, pinned, pinRank, label, metadata, wait }) |
POST /v1/collections/{id}/items: a new document |
replaceDocument(id, itemId, { ... }) |
PUT: the document replaced whole; a new text, format or title is a new version, the old one serving until the new one is ready; the same text again changes nothing and is free |
updateItem(id, itemId, { reference, label, metadata, pinned, pinRank, title }) |
PATCH with only the fields given: applied at once, never re-embedded or billed; a new title is a new version |
getItem(id, itemId, { includeText }) |
one document, with its stored text when asked |
listItems(id, { offset, limit, reference, reference_prefix, q, sort }) |
one page of documents, never their text; q (1 to 200 characters) finds documents whose title, file name or reference holds it, ignoring case, and sort is newest (the default), oldest or title; the page echoes both |
listChunks(id, itemId, { offset, limit }) |
one page of a document's chunks: chunk, heading, byte_start, byte_end, page_start, page_end, tokens, text |
deleteItem(id, itemId) |
the document, its text and its chunks deleted at once |
deleteFailed(id) |
DELETE /v1/collections/{id}/items?status=failed: every failed document deleted, answering { deleted: n }; a failed document otherwise stays visible with its error and counts toward nothing |
dn.uploadDocuments(documents) |
1 to 16 files uploaded ahead of time, one upload id each, for a later Document.fromUploadId; an upload not named within its hour expires |
addDocument, replaceDocument and updateItem send an Idempotency-Key, a new UUID per call and the same on every attempt of it, so the API adds and bills a document once however often the call is tried. When a file this package uploaded is refused as expired, consumed, not found or incomplete, it is uploaded once more and the call sent again with a new key; an upload id you gave is never uploaded again.
Checked before sending, as the API checks them: a title of 1 to 200 characters (required for a text and for a text file without a name), a text that is not empty, format markdown or text, a scopeKey of 1 to 40 of a-z, 0-9 and _, a pinRank from 0 to 1,000, reference, label and metadata as for faces, a file of at most 50 MB that is not empty and of a supported type, a page's offset and limit, and listItems' q and sort.
| Supported | |
|---|---|
| Files | Text (.txt), Markdown (.md), PDF, DOCX, PPTX, XLSX, HTML, and PNG, JPEG, TIFF and WebP images |
| A file | At most 50 MB; a PDF at most 100 pages |
| A document's text | At most 512 KiB, after conversion |
A knowledge call costs USD 0.001, one a request whatever it retrieves, a call that gives only pins included; a call that timed out, failed or was skipped is free. Adding documents is billed per million newly embedded tokens, and storage per chunk a month.
Status, errorType |
Error | When |
|---|---|---|
409 knowledge_collection_empty |
DecisionNodeError with details.items and details.failed |
A request with require: true on a knowledge base with nothing ready and nothing in progress |
529 booster_unavailable |
OverloadedError |
A request with require: true while no document is ready yet but some are in progress. Retried after its Retry-After (5 seconds) |
529 booster_unavailable, details.reason ingestion_delayed |
OverloadedError |
The same, while documents have waited past 15 minutes. Thrown at once with Retry-After: 60, never retried: when to try again is yours to decide |
429 rate_limit_error, details.reason ingest_queue_full or ingest_tokens_per_hour |
RateLimitError |
Your workspace's ingestion rate. Retried like every 429, after its Retry-After |
413 item_too_large |
RequestTooLargeError |
A text over 512 KiB once normalised |
422 invalid_text |
ValidationError |
A text that is not UTF-8 or holds a NUL |
402 insufficient_credit |
InsufficientCreditError with details |
Your balance cannot pay for the new text |
409 reference_taken, pinned_budget_exceeded, chunks_limit_reached |
DecisionNodeError with details |
A reference another document holds (details.item_id), pins past the knowledge base's pinned limit, your workspace's chunk limit |
400 collection_type_mismatch |
BadRequestError |
A face collection named where a knowledge base is meant, or the reverse |
400 upload_purpose_mismatch |
BadRequestError |
An image upload named as a document, or a document upload named as an image |
403 key_scope_denied |
PermissionDeniedError |
search, includeText or a document upload from a key that is not full-scope |
A Manage device keys key, kept on your provisioning server, makes a Decisions-only key for each device as it enrolls, and changes or revokes it later. client.keys returns the API's object as sent.
const admin = new DecisionNode({ apiKey: process.env.DECISIONNODE_MANAGE_KEY });
const { key, secret } = await admin.keys.create({
name: 'Kiosk 0412',
deviceId: 'kiosk-0412',
labels: { store: '118', fw: '2.4' },
limits: { requests_per_second: 5, tokens_per_second: 20000 },
dailySpendCap: { usd: 2.0 },
idempotencyKey: 'enroll-kiosk-0412', // a retried enrollment gets the same key back
});
// Hand `secret` to the device once; never log it.
await admin.keys.update(key.id, { labels: { store: '120' } });
while ((await admin.keys.revokeByLabels({ store: '118' })).more) {
// more keys match: call again until `more` is false
}| Call | Sends |
|---|---|
create({ name, deviceId, labels, limits, dailySpendCap, expiresAt, maySearchCollections, idempotencyKey }) |
POST /v1/keys, each field only when given: { key, secret }, the secret shown only here |
list({ labels, deviceId, status, limit, cursor }) |
one page of keys, newest first: { items, next_cursor }. labels keeps the keys with all of them; status is active (the default), expired, revoked or all; limit 1 to 200 |
all({ labels, deviceId, status, limit }) |
every key list finds, its pages followed to the end |
update(keyId, { name, labels, limits, dailySpendCap, expiresAt, maySearchCollections }) |
PATCH with only the fields given: labels replaces the whole set, and null clears labels, limits, dailySpendCap or expiresAt. A key's device id never changes |
revoke(keyId) |
DELETE: the key is revoked at once |
revokeByLabels(labels, { deviceIds, dryRun }) |
every active key with all of labels, at most 10,000 a call: { revoked, more }; call again while more is true. dryRun: true counts them instead: { would_revoke, more } |
create sends an Idempotency-Key: yours when you pass idempotencyKey (one per enrollment), else a new UUID per call, the same on every attempt, so a retried call makes one key. A retry with the same key and body within 24 hours returns the same key and secret.
What the API checks by fixed rules is checked before anything is sent, with its messages: a key's name (1 to 48 characters once trimmed), a device id (1 to 128 printable ASCII characters, not starting with a space), labels (at most 16, each name 1 to 32 of a-z, 0-9, _, . and -, each value 1 to 64 characters), limits being an object, a dailySpendCap from USD 0.01 to 1,000 in whole cents, an update with nothing to change, a revokeByLabels without a label or with over 1,000 deviceIds, and list's filters and limit. A limit's ceiling (your workspace's own), the expiry's window and a name's characters are the API's to judge. The whole flow is in Keys on devices.
What each key used and cost over a period: a Full access key reads every key of its workspace, a Manage device keys key its Decisions-only keys. client.usage returns the API's object as sent.
const one = await dn.usage.key('key_7c2e9a41b0d34f6e8a5c1d2b3e4f5a60', { granularity: 'day' });
const items = await dn.usage.all({
start: '2026-10-01T00:00:00Z',
end: '2026-11-01T00:00:00Z',
labels: { customer: 'acme' },
status: 'all',
});
for (const item of items) console.log(item.key, item.totals);| Call | Sends |
|---|---|
key(keyId, { start, end, granularity, groupBy }) |
GET /v1/keys/{key_id}/usage; without start and end, the UTC month so far |
list({ start, end, granularity, groupBy, labels, deviceId, keyIds, scope, status, includeEmpty, limit, cursor }) |
GET /v1/usage, one page of every key's usage: { period, as_of, items, next_cursor }; pass next_cursor as cursor until it is null |
all({ start, end, ... }) |
list's parameters but cursor: every page's items, in order |
start and end are RFC 3339 times, rounded to the hour. granularity (none, hour, day or month) adds buckets, and groupBy (model or channel) by_model or by_channel. labels keeps the keys with all of them, keyIds (at most 100) the keys named, scope (full, decide or keys) and status (active, expired, revoked or all) the keys of that kind or state; includeEmpty: true lists keys with no usage too; limit is 1 to 1,000. A page can hold fewer keys than limit, so only next_cursor says when the list is done: all follows it for you. These query rules are checked before anything is sent, with the API's messages; the period's own rules are the server's. See Usage.
Every error is a DecisionNodeError, exported under that name, with status, errorType, requestId and reason (the API's explanation in words, from the x-decisionnode-error header). message is the API's own message exactly as sent, and empty when the response carried none; when no response arrived, it describes what went wrong.
| Status | Error | Extra fields |
|---|---|---|
| 400 | BadRequestError; ContextLimitError when the request is over the model's token limit |
|
| 401 | AuthenticationError (also thrown before sending when no key is set) |
|
| 402 | InsufficientCreditError |
|
| 403 | RefusalError when the request was refused under the usage policy (category, safety); every other 403 is a PermissionDeniedError: a booster turned off for your workspace (booster_not_enabled), a suspended workspace, or a call outside the key's kind (key_scope_denied); for a publishable key also origin_not_allowed and model_not_allowed, its models in details.allowed; told apart by errorType |
category, safety on a RefusalError |
| 404 | NotFoundError |
|
| 413 | RequestTooLargeError: the body is over 25 MiB, or a document's text over 512 KiB (item_too_large); never retried. Send large images as uploads |
|
| 422 | ValidationError |
entries |
| 429 | RateLimitError; SessionLimitError when the key holds as many sessions as it may; errorType key_spend_cap_reached for a key past its daily spend cap, with details resets_at, cap and spent; a publishable key's per-address rate and calls in flight with details.reason client_rate or in_flight |
retryAfter (seconds, at most 60) |
| 529 | OverloadedError, also when a required booster failed (booster_unavailable); one whose details.reason is ingestion_delayed is thrown at once, never retried (see Knowledge) |
retryAfter (seconds, at most 60) |
| other 5xx, a 2xx whose body is not JSON, or a helper's own answer missing or malformed | ServerError |
|
| no response | ConnectionError, TimeoutError |
|
| any other status: 3xx, 405, 409, 410, 408 after its retries | DecisionNodeError itself |
The service's edge or a proxy may answer with a page of its own instead of the API's JSON error (a 408 for an upload that stalls, a 413 for a body far over the limit, a 502). Such an error is classed by its status alone, as in the table (a 400 is a BadRequestError, never a ContextLimitError; a 403 a PermissionDeniedError, never a RefusalError; a 422 a ValidationError with no entries), with errorType null, requestId and reason from the headers when sent, and the client's own message: HTTP <status> <text>, then the request id when there is one, for example HTTP 413 Content Too Large or HTTP 408 Request Timeout (request id req_...). The page itself is never read into the error. Retries are the same as for any body: a 408 is retried, a 413 never.
Every error also has boosters: for a request that named boosters, each booster's status and time from the x-decisionnode-boosters header (see "Boosters"); an empty object otherwise.
Every error also has details: the error body's detail.details when it is an object, for example { reason: 'enrollment_unpriced' } on an enrollment's 529, which is never retried; null otherwise.
A failed upload to storage is a DecisionNodeError with the storage's status, errorType upload_failed, and the storage's error code in reason. Batch errors with their own errorType (batch_limit_exceeded, invalid_batch_state, results_not_ready, results_expired) use the classes above by status.
Only 200 to 299 is a success. Redirects are never followed, so your key is never sent to another URL or over plain HTTP: a 3xx is an error with its status. This holds for your own fetch too: it must be confirmed with fetchHonoursManualRedirect: true, it is called with redirect: 'manual', and a response it reports as redirected is refused with a DecisionNodeError, its body never read (see "Your own fetch").
Problems with your own arguments are not DecisionNodeErrors: they throw JavaScript's own TypeError or RangeError (an empty question map, a bad base URL, timeout, maxRetries or inlineThreshold, a concurrency below 1, a malformed request in decideMany or a batch, a pollInterval below 10 seconds (100 ms for waitUntilReady), a waitUntilReady timeout that is not above 0, a document file that cannot be read, a fetch without fetchHonoursManualRedirect: true, TLS checks turned off for an https base URL, an image file that cannot be read, a value that cannot be serialised as JSON). A request the API would refuse is not an argument problem: it throws the API's own error class (see "Checked before sending").
import { ContextLimitError, DecisionNodeError, RefusalError } from '@decisionnode/sdk';
try {
await dn.decide({ state, questions });
} catch (err) {
if (err instanceof ContextLimitError) console.error('Too long:', err.reason);
else if (err instanceof RefusalError) console.error('Refused:', err.category);
else if (err instanceof DecisionNodeError) console.error(err.status, err.message, err.requestId);
else throw err;
}Each attempt times out after 10 seconds (30 seconds for a request whose boosters name ocr, and 20 seconds for a document added or replaced with wait, unless you set timeout), counted from sending the request to the last byte of the response body. Requests are retried twice after the first attempt on 408, 429, every 5xx (529 included), timeouts and connection errors, except that a decision naming ocr is never retried after a timeout (see Boosters). The wait is the API's retry-after-ms when sent, else Retry-After in seconds or as an HTTP date, capped at 60 seconds; without either it is exponential backoff from 0.5 s with jitter, up to 8 s. 400, 401, 402, 403, 404, 413 and 422 are never retried, and neither is a 429 key_spend_cap_reached: the key's cap resets at 00:00 UTC, so it is thrown at once. A decision changes nothing on the server, so a retry is otherwise safe.
Each storage PUT of an upload has its own timeout, uploadTimeout (60 seconds by default for an image, 120 seconds for a document's file; set, it is used for both), apart from timeout, since a large image on a slow uplink needs longer than a decision. It is retried the same way on 5xx, timeouts and connection errors; a 412 on a retry means an earlier attempt stored the image, and counts as success. A 403 after the upload URL's 5 minutes have passed, by the client's clock, gets a fresh URL once; a second such 403 is thrown. Batch results stream in, and each read of the stream must bring data within the timeout.
The hosted API runs a safety check on every decision. decision.safety holds what it found:
d.safety; // { checked: true, harm: 0.031, selfHarm: 0.004, flagged: [] }flagged lists the categories above your key's thresholds. When the check did not run, safety is { checked: false, reason }. A self-hosted server sends no safety information and safety is null. A refused request throws RefusalError and is never retried. In a session, each reply carries the check's result in its body form as safety, and a refused frame is a FrameError with errorType refusal_error.
The check's result always arrives in the x-decisionnode-safety response header. To also receive it in the response body, send the request header x-decisionnode-safety-body: true. Set it once for every request through the client's headers option, then read it from the parsed body:
const dn = new DecisionNode({ headers: { 'x-decisionnode-safety-body': 'true' } });
const d = await dn.decide({ state, questions });
d.raw.safety; // the check's result, as the API sent it in the body
d.safety; // the same check, parsed from the response headerWithout the header the body carries no safety field.
const models = await dn.models();
models.map((m) => [m.name, m.limits]);| Model | |
|---|---|
decisionnode-moe |
The main model, always its current release (default) |
decisionnode-flash |
The fast, lower-cost model, always its current release |
A pinned release is decisionnode-<family>-<version>, for example decisionnode-moe-<version>, with the versions models() lists. Every answer names the pinned id that answered in decision.model, so you can log which release decided. model is free text: a pinned id, a fine-tuned model's name, and the older ids the API still accepts (decisionnode-1.0, decisionnode-1.0-flash, decisionnode-latest, decisionnode-flash-latest) are sent as given.
Limits differ by model version; models() returns each model's published limits in limits, wire names as sent, its prices included (price_per_million_input, price_per_million_output, price_per_million_image and price_per_million_video, in USD per million tokens, and batch_price_factor). The number, upload, session and batch limits are also typed fields: numberOneQuestionValues, numberMaxGridValues, numberDefaultRange, numberDefaultStep, uploadMaxBytes, uploadTtlSeconds, uploadMediaTypes, sessions and batch, each null when a model does not publish it. So are the prices, as sent: pricePerMillionInput, pricePerMillionOutput, pricePerMillionImage, pricePerMillionVideo and batchPriceFactor, each null when the entry states none (a fine-tuned model states no batchPriceFactor).
A request for decisionnode-moe that no ready worker can take at once is answered by Flash, billed at Flash's price. decision.model is the model that answered, and decision.fallback names both: the model that answered by its pinned id, and the model asked for as it was asked (a family id or an older id as the family id, a pinned id as itself); it is null on an ordinary answer:
const d = await dn.decide({ model: 'decisionnode-moe', state, questions });
if (d.fallback !== null) {
d.fallback.servedModel; // 'decisionnode-flash-<version>', the model that answered
d.fallback.requestedModel; // 'decisionnode-moe', the model asked for
}With fallback: false such a request gets the 529 OverloadedError instead, retried like every 529. decide, the helpers, decideMany requests and batch requests take fallback; it is sent only when given, and left out the API's default (true) applies. Sessions, batch lines and fine-tuned models never fall back.
A model your workspace fine-tuned in the console is called by its name, <base>@<workspace>/<name>, which means the version deployed most recently, or <base>@<workspace>/<name>:<n> for one version:
const d = await dn.decide({ model: 'decisionnode-moe@acme/fraud', state, questions });
d.model; // 'decisionnode-moe@acme/fraud:3', the version that answeredThe name is sent as given, in model on any call or as the client's model. Only your workspace's keys can use it, and it answers like its base: the same question types, answers, limits and errors. A name that does not exist, a version that is not deployed and another workspace's model all throw BadRequestError with the message Unknown model: <name>.
models() lists your workspace's fine-tuned models beside the catalogue. Their entries add fineTuned (true), base (the base model's pinned id), adapterVersion (the version number; on a plain name, the version it resolves to) and status (deployed, which answers decisions, or ready: trained and checked, not deployed). Each is null on a catalogue entry.
| Setting | Option | Environment variable | Default |
|---|---|---|---|
| API key | apiKey |
DECISIONNODE_API_KEY |
none: a call without a key fails before sending |
| Base URL | baseUrl |
DECISIONNODE_BASE_URL |
https://api.decisionnode.com |
| Model | model |
DECISIONNODE_MODEL |
decisionnode-moe |
| Inline threshold | inlineThreshold |
DEFAULT_INLINE_THRESHOLD, 524,288 bytes |
|
| Upload timeout | uploadTimeout |
DEFAULT_UPLOAD_TIMEOUT, 60,000 milliseconds per storage PUT of an image; DEFAULT_DOCUMENT_UPLOAD_TIMEOUT, 120,000, of a document's file |
|
| Clock | clock |
Date.now; the time, in epoch milliseconds, that upload URLs and cached uploads are judged by |
The client sends no telemetry and contacts nothing but the base URL, and the storage URLs the API returns for an upload. Besides your key, your own headers and the content headers, the only header it sends to the API is User-Agent: decisionnode-node/<version>. To see each attempt, pass logger: (line) => console.debug(line): it prints the method, path, status, request id and time, never the state, the questions or the key.
SPEC_VERSION holds the version of the DecisionNode SDK specification this package implements, "1.4".
MIT. Copyright (c) 2026 Bynn Intelligence, Inc.