DecisionNode is a decision engine: send it text, JSON or images with a question, and it answers with calibrated probabilities.
The official Java and Kotlin client for the DecisionNode API: ask typed questions about any state and get probabilities back.
Java 17 or later. Works from Kotlin as is. Implements version 1.4 of the DecisionNode SDK
specification, which DecisionNode.SPEC_VERSION holds: decisions with truth, choice, score,
number, point and box questions, up to 16 images and videos sent as frames, boosters and
their evidence, knowledge bases of your own documents, fine-tuned models, the models list, image
uploads, sessions and the Batch API.
Gradle:
dependencies {
implementation("com.bynn:decisionnode:0.3.2")
}Maven:
<dependency>
<groupId>com.bynn</groupId>
<artifactId>decisionnode</artifactId>
<version>0.3.2</version>
</dependency>The only dependency is Jackson; HTTP and WebSockets go through the JDK's java.net.http.
export DECISIONNODE_API_KEY="dn_..."import com.bynn.decisionnode.*;
import java.util.Map;
import static com.bynn.decisionnode.Questions.*;
DecisionNode dn = new DecisionNode(); // reads DECISIONNODE_API_KEY
Decision d = dn.decide(DecideRequest.builder()
.state(Map.of("ticket", "I was charged twice and nobody has replied for 3 days."))
.question("route", choice("Where should this go?",
options("billing", "money", "bug", "broken", "account", "login")))
.question("urgency", score("How urgent is this?", "routine", "today", "urgent", "critical"))
.question("escalate", truth("Escalate to a human now?"))
.build());
d.choice("route").choice(); // "billing"
d.choice("route").probabilities(); // {billing=0.87, bug=0.08, account=0.05}
d.score("urgency").score(); // 2.31
d.truth("escalate").truth(); // 0.94Kotlin:
import com.bynn.decisionnode.DecisionNode
import com.bynn.decisionnode.Questions.choice
import com.bynn.decisionnode.Questions.truth
val dn = DecisionNode()
val d = dn.decide(
mapOf("ticket" to "I was charged twice and nobody has replied for 3 days."),
mapOf(
"route" to choice("Where should this go?", mapOf("billing" to "money", "bug" to "broken")),
"escalate" to truth("Escalate to a human now?"),
),
)
d.truth("escalate").truth() // 0.94
d["route"] // Answer: TruthAnswer, ChoiceAnswer, ScoreAnswer, ... or UnknownAnswer| Builder | Answer | Read |
|---|---|---|
truth(instructions), truth(instructions, ifTrue, ifFalse) |
TruthAnswer |
truth(): probability it is true |
choice(instructions, options) |
ChoiceAnswer |
choice(), confidence(), probabilities() by option name |
score(instructions, levels) |
ScoreAnswer |
score(), confidence(), probabilities() and legend() by level index |
number(instructions), number(instructions, min, max), number(instructions, min, max, step) |
NumberAnswer |
number(), expected(), confidence(), probabilities() by the value as the API wrote it ("7", "3.5") |
point(instructions), point(instructions, image, units) |
PointAnswer |
x(), y(), present(), confidence(), image(), width(), height() |
box(instructions), box(instructions, image, units) |
BoxAnswer |
x1(), y1(), x2(), y2(), present(), confidence(), image(), width(), height() |
state, instructions, descriptions and levels take a string or any value Jackson can serialise: maps, lists, records, POJOs.choicetakes a map of option name to description, a list of names, or names as arguments. Option names are sent and returned exactly as written. Useoptions(...)or aLinkedHashMapto keep their order.scorelevels run from low to high.numberasks for a value on the gridmin,min + step, ... up tomax. A bound left null is left out of the request, so the API's default applies (0, 255 and 1). A grid holds at most 4,096 values. Pass aBigDecimalto send an exact decimal.pointandboxask for a place in one of the request's images, below.- A plain map in the API's wire form is accepted wherever a builder is, so a request copied from the API reference runs unchanged.
A point or a box is a place in one of the request's images:
Decision d = dn.decide(DecideRequest.builder()
.image(Image.fromUrl("belt", "https://files.example.com/line-3/cam-2/0917.jpg"))
.question("pick", point("Point to the yellow package closest to the robot arm."))
.question("damage", box("Draw a box around the damaged package.", "belt", null))
.question("any_damage", truth("Is any package on the belt damaged?"))
.build());
PointAnswer pick = d.point("pick"); // x 812, y 455, present 0.97
BoxAnswer damage = d.box("damage"); // x1 300, y1 120, x2 520, y2 410x and y are measured from the top-left corner of the image, x to the right and y down; a
box runs from its top-left corner (x1, y1) to its bottom-right corner (x2, y2). units
is "pixels" (the default: pixels of the image as sent) or "normalized" (fractions of the
width and the height). present() is the probability that what the instructions describe is in
the image at all; confidence() the probability that the place is right if it is. image(),
width() and height() name the image looked in and its size in pixels. The request needs an
image, and image names the one to look in by its id when it carries two or more (null leaves it
out); a reference by number ("Picture 2") goes in the instructions.
Answers are read by question id: d.truth("id"), d.choice("id"), d.score("id"),
d.number("id"), d.point("id"), d.box("id"), or
d.get("id") and d.answers() for the generic Answer. Answer is a sealed interface, so a
switch or Kotlin when over it is exhaustive. An answer of a type newer than this SDK comes
back as UnknownAnswer with its fields in raw(). So does an answer of a known type whose
fields are missing or of the wrong type; the rest of the decision is read as usual.
Numbers are exactly what the API sent: no rounding and no renormalising.
A Decision also carries model() (the pinned id of the release that answered), usage(),
requestId(), latencyMs(), safety(), warnings(), evidence() (see Boosters), fallback()
(see Fallback) and raw(), the parsed body.
usage().cost() is what the answer cost, in USD, exactly as the API sent it: usd() (the amount
booked for this answer, at your workspace's rates), modelUsd() and boostersUsd() (its two
parts) and freeTier() (true when the free tier covered the request, modelUsd() then 0). Read
usd() as the total; it is never computed from the parts, since adding doubles can differ from
the booked amount in the last digits. A batch result line and a session's open and frame replies
carry their own. It is null when the answer does not carry a cost.
| Method | Does |
|---|---|
decide(state, questions), decide(state, questions, images, model), decide(state, questions, images, videos, model[, boosters]), decide(DecideRequest) |
One decision |
decideMany(requests), decideMany(requests, concurrency) |
Many decisions, at most concurrency (default 8) at a time. One DecisionResult per request, in input order; one failure does not stop the others. Every request is checked first: a concurrency below 1 or an invalid request (an empty question map, a value that cannot be serialised) throws IllegalArgumentException and nothing is sent, not even the valid requests. |
check(state, instructions), check(state, instructions, ifTrue, ifFalse), check(state, instructions, ifTrue, ifFalse, images[, videos], model) |
One truth question; returns the TruthAnswer (see below for a malformed answer) |
classify(state, instructions, options), classify(state, instructions, options, images[, videos], model) |
One choice question; returns the ChoiceAnswer |
score(state, instructions, levels), score(state, instructions, levels, images[, videos], model) |
One score question; returns the ScoreAnswer |
number(state, instructions, min, max, step), number(..., images[, videos], model) |
One number question; returns the NumberAnswer |
gate(action, context), gate(action, context, images[, videos], model) |
Should this action run? See below |
upload(images) |
Uploads images now and returns their upload ids; see Images |
openSession(request), supervise(request, feed, onReply) |
Sessions; see below |
batches(), runBatch(requests) |
The Batch API; see below |
models() |
The models, their published limits and their prices, as ModelInfo entries |
decideRaw(body) |
Sends the body exactly as given, unchecked, and returns the parsed response, for API fields this SDK does not know yet |
Each ModelInfo carries the model's prices in USD as sent, pricePerMillionInput(),
pricePerMillionOutput(), pricePerMillionImage() and pricePerMillionVideo(), and the share of
the live price a batch line is billed, batchPriceFactor(); each is null when the entry has none.
Every method has an ...Async form returning a CompletableFuture:
CompletableFuture<TruthAnswer> f = dn.checkAsync(email, "Is this email a refund request?");
f.thenAccept(a -> System.out.println(a.truth()));In the helpers, as in decide, images and videos may be null and a null or empty model
means the client's. Each helper's longest form also takes boosters last (see Boosters), for
example check(state, instructions, ifTrue, ifFalse, images, videos, model, boosters).
check, classify, score and number promise a typed answer. When their own answer is missing or
malformed, they throw ServerError with status() 200, the requestId() and reason() from
the x-decisionnode-error header. decide returns the same answer as an UnknownAnswer.
A client is thread-safe and reuses its connections (one HTTP/2 connection over TLS carries every call): create one and share it.
List<DecisionResult> results = dn.decideMany(List.of(
DecideRequest.of(ticketA, Map.of("spam", truth("Is this spam?"))),
DecideRequest.of(ticketB, Map.of("spam", truth("Is this spam?")))));
for (DecisionResult r : results) {
if (r.isSuccess()) {
System.out.println(r.decision().truth("spam").truth());
} else {
System.out.println(r.error().getMessage());
}
}gate(action, context) sends the state {"action": ..., "context": ...} with three questions
whose 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?": allow "safe to run without asking", confirm "a person should confirm it first", block "should not run" |
Decision g = dn.gate("DROP TABLE invoices;", Map.of("environment", "production"));
g.choice("verdict").choice(); // "block"Give an image by URL when you can. It is the fastest form: nothing is uploaded, and the API fetches it directly.
Decision d = dn.decide(
Map.of("claim", Map.of("total", "740.00")),
Map.of("matches", truth("Does the receipt total equal the claim?")),
List.of(Image.fromUrl("receipt", "https://example.com/receipt.jpg")), // recommended
null);Every form:
| Form | Sent as |
|---|---|
Image.fromUrl(id, url) |
{"id", "url"} as given; never fetched by this client. https only, at most 2,048 characters |
Image.fromPath(id, path), Image.fromBytes(id, bytes[, mediaType]), Image.fromBase64(id, text[, mediaType]), Image.fromStream(id, in[, mediaType]) |
Checked here, then inline as base64 or uploaded, by the threshold below |
Image.fromUpload(id, uploadId[, mediaType]) |
{"id", "upload_id"} as given |
Local bytes are checked before anything is sent: JPEG, PNG or WebP by their first bytes (a media
type you give must match them), at most 10 MB, at most 40 megapixels as read from the image header
(the image is never decoded), at most 16 images in a request, and ids unique. A 17th image raises
the API's ValidationError with a too_long entry; any other fault raises the API's own
BadRequestError, with no request made. upload takes up to 16 images a call. A file is read when the Image is made, so a
file that cannot be read throws IllegalArgumentException there.
When a request's local images total under the inline threshold (DecisionNode.DEFAULT_INLINE_THRESHOLD,
524,288 bytes; inlineThreshold(bytes) on the builder changes it), they go inline as base64 in the
request. At or above it, they are uploaded first: one POST /v1/uploads for all of them, then one
PUT per image to storage, in parallel, carrying exactly the headers the API returned (never your
API key, never your custom headers), and the request names them by upload id. Uploaded bytes are
remembered in the client's memory by their SHA-256 for the upload's life (until 5 minutes before
it expires, at most 256 entries), so the same bytes in a later call, in decideMany, in a session
frame or in a batch line go by their upload id whatever their size. If the API answers that an
upload this client placed has expired or been used up, the client uploads it again and sends the
request once more. No image is resized or re-encoded.
A request body may be at most 25 MiB (DecisionNode.MAX_BODY_BYTES, 26,214,400 bytes), counted as
it would be sent: inline images and video frames included, an image to upload named by its upload
id. A larger body raises RequestTooLargeError before anything is sent or uploaded, in decide,
the helpers, decideMany (for the whole call) and each batch line. Images given as a path or bytes
above the inline threshold are uploaded, so they never count toward it; base64 given in a
wire-form map is sent as given and does.
List<String> ids = dn.upload(List.of(Image.fromPath("scan", Path.of("scan.png")))); // upload now
dn.decide(state, questions, List.of(Image.fromUpload("scan", ids.get(0))), null);Each PUT has its own timeout (uploadTimeout, 60 s by default), apart from the API attempt's,
since an image of 10 MB on a slow uplink needs longer than an API call. A failed PUT is retried
on 5xx, timeouts and connection errors; one that still times out raises TimeoutError, and a
storage error it cannot recover from raises DecisionNodeError with the storage status,
errorType() upload_failed and the storage's error code in reason(). A PUT answered 403 once
its URL has expired is presigned again, once.
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.
Decision d = dn.decide(DecideRequest.builder()
.state("Expense claim 4411: 740.00 EUR.")
.image(Image.fromUrl("receipt", "https://files.example.com/claims/4411/receipt.jpg"))
.image(Image.fromUrl("claim_photo", "https://files.example.com/claims/4411/photo.jpg"))
.question("matches", truth("Does the total on `receipt` match the amount in the claim?"))
.build());A video travels as the frames you already hold, each with its time in seconds, and the server reads them as one video, in order and with their times. Only frames are sent: no video file, no video URL and no sound.
List<VideoFrame> frames = new ArrayList<>();
String[] files = {"dock-0000.jpg", "dock-0001.jpg", "dock-0002.jpg"};
for (int i = 0; i < files.length; i++) {
frames.add(VideoFrame.fromBytes(i * 0.5, Files.readAllBytes(Path.of(files[i])))); // 0, 0.5 and 1 s
}
Decision d = dn.decide(DecideRequest.builder()
.state("Warehouse camera 4, loading dock, shift B.")
.video(Video.of("dock", frames))
.question("fall", truth("Does a person fall in the video?"))
.question("forklifts", number("How many forklifts pass the door?", 0, 50))
.build());
d.truth("fall").truth();
d.warnings(); // the server's notes when it read a video at a lower rate or resolution| Form | Sent as |
|---|---|
VideoFrame.fromBytes(t, bytes[, mediaType]) |
A frame t seconds into the video, its bytes as base64; without a media type it is read from the first bytes (JPEG, PNG or WebP) |
VideoFrame.fromBase64(t, text, mediaType) |
A frame given as base64 text, sent as given, with its media type |
Video.of(id, frames), .fps(...), .start(...), .end(...) |
A video: its id (unique across the request's images and videos), its frames with strictly increasing times, and optionally the most frames a second to read (above 0 and at most 4; the API's default is 2) and the window [start, end) on the frames' own times |
A request takes up to 4 videos, each a Video or a map in the wire form, and a frame may be a map
too; both are checked by the same rules. decide, the helpers, decideMany and batch requests
take videos; sessions do not. Frames always go inline: they are never uploaded or
cached, and no frame is resized or re-encoded. Decision.usage() carries videoFrames() (the
frames read) and videoSeconds() (the seconds of the windows read), both null for a request
without videos, and Decision.warnings() the server's notes exactly as sent (empty when there are
none). To turn a video file into frames, use the dn command line tool: dn --video dock.mp4.
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, and a request without boosters is answered exactly as before. These boosters are available:
| Booster | Reads | Tells the model | Options |
|---|---|---|---|
age |
A picture of a person | How old the person is, with a range and a confidence (the largest face is read) | None |
ai_generated |
Any picture | How likely the picture was made by an image generator, which generator most likely when it reads as generated, and whether the file carries trusted Content Credentials (C2PA) and who signed them | None |
face_detection |
Any picture | How many faces it shows and where: a box and a confidence per face; a picture with no face reads 0 faces | None |
face_recognition |
A picture of a person | Whether a face matches an item of the collection the request names: one of your own collections, or celebrities, open to every workspace, where a match gives the person's name. Per match a box, a score and the item's reference, with its name or label |
collection (required: your own collection's id, or celebrities); threshold (0 to 1, default 0.5: matches below it are not sent) |
ocr |
Any picture | The text it shows, all of it by default, or what a prompt asks for: a formula in LaTeX, a table in HTML or Markdown, the fields of a JSON schema the prompt gives |
prompt (at most 2,000 characters; left out, all the text, plain) |
bynn_depth |
Any picture | Depth Vision: distance in meters to the nearest obstacle straight ahead (the floor left out), seven directions from left to right, and a grid of up to 16 by 16 cells | grid_size (2 to 16, default 8) |
knowledge |
The request's state and questions, never a picture | The passages of your own documents that apply, from one of your knowledge bases: pinned rules always, then the best-matching passages within a token budget, as facts and rules to apply (see Knowledge) | collection (required); query, scope, match, filter, top_k, max_tokens, pinned, min_score, include_text |
bynn_segment |
Any picture | Segment Vision: what you name in words, each match's outline, box and centre and, with depth on, its distance and size in meters | prompt (required: one phrase, or a list of 1 to 4, each at most 120 characters); max_instances (1 to 16, default 8); min_score (0 to 1, default 0.25); polygon_points (3 to 64, default 24); depth (default true) |
Options go in Booster.of(name).options(map), and the API checks them: a bad value or an unknown
key is its 400 api_usage_error. Depth Vision's and Segment Vision's distances and sizes are
estimates from one picture, for decisions and planning, never a safety stop.
Decision d = dn.decide(DecideRequest.builder()
.image(Image.fromUrl("hall", "https://files.example.com/robot/hall.jpg"))
.booster(Booster.of("bynn_segment").options(Map.of("prompt", List.of("door", "chair"))))
.question("door_open", truth("Is there an open door the robot can pass through?"))
.build());
SegmentReading hall = (SegmentReading) d.evidence("bynn_segment").results().get(0);
for (SegmentInstance found : hall.instances()) {
// centroid as fractions of the picture; depth and sizeM null with depth off
System.out.println(found.label() + " at " + found.centroid() + ", "
+ (found.depth() == null ? "no distance" : found.depth().median() + " m"));
}Decision d = dn.decide(DecideRequest.builder()
.image(Image.fromUpload("selfie", "upl_..."))
.image(Image.fromUrl("upload", "https://files.example.com/upload.jpg"))
.boosters(List.of("age", "ai_generated")) // sent as "boosters": ["age", "ai_generated"]
.question("adult", truth("Is the person in `selfie` an adult?"))
.question("synthetic", truth("Is `upload` AI-generated?"))
.build());
d.truth("adult").truth(); // 0.97
Evidence age = d.evidence("age");
age.status(); // "ok"
for (Reading r : age.results()) {
if (r instanceof AgeReading a && a.faces() > 0) {
System.out.println(a.image() + " " + a.ageYears() + " (" + a.low() + " to " + a.high() + ")");
}
}
AiGeneratedReading g = (AiGeneratedReading) d.evidence("ai_generated").results().get(1);
g.generated(); // 0.93
g.method(); // "inference", or "c2pa" from Content Credentials
g.c2paSigner(); // who signed them, or null
d.usage().boosterTokens(); // the readings' share of the input tokens
d.usage().boosters(); // booster calls billedA booster that needs a setting is a Booster object, sent as
{"name": ..., "images": [...], "require": true, "options": {...}} with each setting only when
given (a Booster with no setting is sent as its bare name). Here the age booster reads every image, and the AI-generated booster reads only upload
and must answer:
DecideRequest.builder()
.image(Image.fromUpload("selfie", "upl_..."))
.image(Image.fromUrl("upload", "https://files.example.com/upload.jpg"))
.booster("age")
.booster(Booster.of("ai_generated").images("upload").require(true))
.question("synthetic", truth("Is `upload` AI-generated?"))
.build();- A booster is named by its name,
booster(name)orboosters(List.of(...)).Booster.of(name)adds settings:images(ids...)(the images it reads),require(true)andoptions(map). A booster in its wire form, a map, is accepted too and sent as given. A request takes up to 4 boosters, each at most once, in the order given.decide, the helpers,decideManyand batch requests take boosters; sessions do not. require(true): a booster that fails or does not answer in time fails the request withOverloadedError,errorType()booster_unavailable, which is retried like any 529. Without it, the decision runs without that reading and its evidence says why.Decision.evidence()holds oneEvidenceper booster the request named, in its order, and is null when the request named none. Each hasversion(),status()(ok,timeout,errororskipped, when none of the request's images is one it reads),latencyMs(),billedCalls()(the calls of that booster billed on this request, null when the API does not say) andresults(), one entry per picture. A picture the booster did not read (it timed out or failed, or could not decode the picture) is anUnknownReadingholding only itsimage(andreadable: false), the status saying why. A reading is anAgeReading(image,faces, andageYears,low,high,confidence, each null whenfacesis 0), anAiGeneratedReading(image,generated,confidence,method,topGeneratorwith itsnameandprobability, null unless the image is likely generated,c2paSigner, andc2pa:untrustedwhen the file's Content Credentials validate but their signer is not trusted), aFaceDetectionReading(facesanddetections, each aFaceDetectionwith itsboxandconfidence), aFaceRecognitionReading(faces,collectionName, andmatches, each aFaceMatchwith itsbox,score,collection,reference,nameorlabel,collectionNameandmetadata), anOcrReading(text,""when the picture shows none, andtruncated, true when the reading was cut to fit the request), aDepthReading(ahead, null when nothing is found ahead, sevendirections,ground, thegridwithgridRowsandgridCols,depthMin,depthMax,depthMedian, and the picture'swidthandheight), aSegmentReading(width,height,prompts, itsinstances, each aSegmentInstancewithlabel,score,box,polygon,area,centroid, andopening,depthandsizeM, each null when absent, plusdepthSourceandassumedHfovDeg), or anUnknownReadingwith its fields inraw()for a booster newer than this SDK.Readingis a sealed interface, so aswitchor Kotlinwhenover it is exhaustive.usage().boosterTokens()is the readings' share of the input tokens, andusage().boosters()the booster calls billed; both are null for a request without boosters.- Billing: a booster call is billed for each booster and image read with status
ok, drawn from the prepaid balance with the request's tokens; each booster's price is on the pricing page. A booster that timed out or failed is free, and so is a refused request's. The readings' tokens are input tokens at the model's price. - The API checks the list before any booster runs and before any charge: a name that is not a
booster is a
BadRequestError(booster_unknown), a booster that needs images on a request without any, or an image id that is not the request's, too (booster_input_mismatch), a booster turned off for the workspace aPermissionDeniedError(booster_not_enabled), and a malformed list (more than 4, a name twice, a key inoptions) aBadRequestError(api_usage_error). Every error of a request that named boosters carriesboosters(): each booster'sstatus()andlatencyMs()by name, from thex-decisionnode-boostersheader, so you can see which required booster failed.
A knowledge base is a collection of type text holding your own documents: policies, manuals,
mission briefs. The knowledge booster gives a decision the passages of them that apply, as facts
and rules to apply, and cites each one.
CollectionsClient kb = dn.collections();
Map<String, Object> policies = kb.create("Claims policy", "text", "mission"); // scope key optional
String col = (String) policies.get("id");
// Markdown held in memory goes inline, in one call.
Map<String, Object> rule = kb.addDocument(col, Document.fromText("""
# Receipts
Reject any claim over 500 SEK without a receipt.
"""),
DocumentOptions.builder().title("Receipts rule").pinned(true).build());
// A PDF from a path is uploaded, then converted to text.
Map<String, Object> manual = kb.addDocument(col, Document.fromPath(Path.of("claims-manual.pdf")),
DocumentOptions.builder().reference("manual-2026").build());
// A mission's brief, scoped by the collection's scope key in its metadata.
kb.addDocument(col, Document.fromText(brief),
DocumentOptions.builder().title("Mission M-42 brief").metadata(Map.of("mission", "M-42")).build());
Map<String, Object> ready = kb.waitUntilReady(col, (String) manual.get("id"));
ready.get("status"); // "ready", or "failed" with its "error"- A document is text (
Document.fromText(text)for Markdown, the default, orDocument.fromText(text, "text")for plain text), a file (Document.fromPath,Document.fromBytesorDocument.fromStream, each with an optional content type and file name), or a knowledge upload made before (Document.fromUpload("upl_..."), fromdn.uploadDocuments(documents)). - Text, and a text or Markdown file under the client's inline threshold, goes inline in the one call. Any other file is uploaded first (a full-scope key) and named by its upload id. A file is converted to text; once the document is ready only its text is kept, and the original file is deleted.
- Ingestion is asynchronous: an added document is
queued, thenprocessing(stageextractingfor a converted file,chunking,embedding), thenreadyorfailed.waitUntilReady(true)inDocumentOptionsasks the server to answer once the document is ready (it waits up to 10 seconds, so the call has 20 seconds per attempt unless you set a timeout).kb.waitUntilReady(col, itemId)reads the document until it isreadyorfailedand returns it; a failed document is returned, not raised, itserrorsaying why.kb.waitUntilReady(col)waits until nothing in the knowledge base is queued or processing. Both read at once, then after 1 second, 1.5 times longer each time up to 10 seconds, and raiseTimeoutError300 seconds after the call;waitUntilReady(col, itemId, timeout, pollInterval)changes both. replaceDocument(col, itemId, document, options)replaces a document whole as a new version; the old one serves until the new one is ready.updateItemchangesreference,label,metadata,pinnedandpinRankat once, never embedded again or billed; a newtitlemakes a new version.getItem(col, itemId, true)returns the document with its storedtext,listChunksits chunks page by page,listItemsthe documents (qfinds them by title, file name or reference,sortorders themnewest,oldestor bytitle),deleteItemone,deleteFailed(col)every failed document at once ({"deleted": n}), anddeletethe knowledge base ({"id", "deleted": true}, or{"id", "status": "purging"}while it is purged). A failed document stays listed with itserroruntil you delete it, and counts toward nothing.addDocument,replaceDocumentandupdateItemcarry anIdempotency-Key, the same on every attempt, so a retried call never adds a document twice.
A rule that must always apply is pinned (pinned(true), ordered by pinRank): every decision
that names the knowledge base gets it. A knowledge base created with a scope key (here mission)
holds global documents and scoped ones, those whose metadata gives the key a value; a decision or a
search names the value in scope and reads the global documents and that scope's.
Decision d = dn.decide(DecideRequest.builder()
.state(Map.of("claim", Map.of("amount", "740.00", "receipt", false), "mission", "M-42"))
.booster(KnowledgeBooster.of(col).scope("M-42").require(true).build())
.question("approve", truth("Should the claim be approved under our policy?"))
.build());
KnowledgeReading k = (KnowledgeReading) d.evidence("knowledge").results().get(0);
for (KnowledgePassage p : k.passages()) {
System.out.println(p.n() + (p.pinned() ? " (pinned) " : " ") + p.title() + " > " + p.heading()
+ ", item " + p.item() + " chunk " + p.chunk() + " bytes " + p.byteStart() + "-" + p.byteEnd());
}
// The cited text: byteStart and byteEnd are UTF-8 byte offsets, end exclusive.
String stored = (String) dn.collections().getItem(col, k.passages().get(0).item(), true).get("text");
byte[] utf8 = stored.getBytes(StandardCharsets.UTF_8);
String cited = new String(utf8, (int) k.passages().get(0).byteStart(),
(int) (k.passages().get(0).byteEnd() - k.passages().get(0).byteStart()), StandardCharsets.UTF_8);Booster.knowledge(col)is the booster with its collection alone;KnowledgeBooster.of(col)setsquery(in place of the one built from the questions and the state),scope,match(metadata equality),filter(a filter tree),topK(0 to 20; 0 is the pins alone),maxTokens("auto"or 128 to 8,192),pinned(false),minScore,includeText(true)(each passage's text in the reading; a full-scope key) andrequire(true). The options are sent as given and checked by the API.- The reading is a
KnowledgeReading:collection,collectionName,corpus,query,budget,scope,warnings(scope_not_named,over_budget,budget_floor,pinned_filtered),passages(each aKnowledgePassage:n,pinned,item,reference,title,heading,chunk,byteStart,byteEnd,tokens,score, null for a pin,lexical,rendered, andtextwithincludeText),omitted,pendingItems,searchandtruncated. A reading of another shape is anUnknownReading. - The byte offsets are UTF-8 byte offsets into the document's stored text, which
getItem(col, itemId, true)returns. A Java string counts UTF-16 units, so encode the text to UTF-8 before slicing it by them. require(true)guarantees the retrieval ran over a ready knowledge base and that every pin in scope was given to the model.
KnowledgeReading r = dn.collections().search(col, "Do claims over 500 SEK need a receipt?",
SearchOptions.builder().scope("M-42").topK(5).build());
r.passages().forEach(p -> System.out.println(p.score() + " " + p.text()));search tries a query without a decision and returns the passages a decision would get, each
with its text. It needs a full-scope key and is billed as one knowledge call.
require(true)on a knowledge base with nothing ready and nothing being added (no documents, or all failed): the baseDecisionNodeErrorwith status 409 anderrorType()knowledge_collection_empty, itsdetails()holdingitemsandfailed.require(true)while no document is ready yet but some are being added:OverloadedError(booster_unavailable), retried after itsRetry-Afterof 5 seconds. When ingestion is delayed,details().get("reason")isingestion_delayed,Retry-Afteris 60 seconds, and the error is raised at once so you decide whether to wait.- Adding documents faster than ingestion takes them:
RateLimitError,details().get("reason")ingest_queue_fulloringest_tokens_per_hour, retried after itsRetry-After. - A text over 512 KiB once normalised:
RequestTooLargeError,errorType()item_too_large. - A title, scope key, reference, label, metadata or pin rank the API would refuse is its
ValidationError, and a file that is empty, over 50 MB or not one of the types below itsBadRequestError, raised before anything is sent.
Text (.txt), Markdown (.md, .markdown), PDF, Word (.docx), PowerPoint (.pptx), Excel
(.xlsx), HTML (.html, .htm) and images (PNG, JPEG, TIFF, WebP). A file's type comes from the
content type you give, else from its name's extension, else from its first bytes; a .docx,
.pptx, .xlsx or text file given as bytes or a stream needs a content type or a file name. At
most 50 MB a file, 100 pages a PDF, and 512 KiB of text after extraction.
Billing: a decision naming knowledge is billed one knowledge call (USD 0.001) whatever it
retrieves, and so is a search; ingestion and storage are priced per token and per chunk. See
the pricing page.
A model your workspace fine-tuned in the console is called by its name in model, by the keys of
that workspace: decisionnode-flash@acme/fraud runs the version deployed most recently, and
decisionnode-flash@acme/fraud:3 pins version 3. It answers like its base: the same question
types, answers, limits and errors. The answer's model() is always the pinned name, so you can log
which version answered. A name that does not exist, is not deployed or belongs to another
workspace is the API's BadRequestError "Unknown model: "; the SDK sends the name as given.
models() lists the workspace's fine-tuned models beside the catalogue, each ModelInfo with
fineTuned() true, base() (the base's pinned id), adapterVersion() (the version number) and
status() (deployed answers decisions, ready is trained but not deployed); a catalogue entry
has them null.
Decision d = dn.decide(DecideRequest.builder()
.state(claim)
.question("fraud", truth("Is this claim fraudulent?"))
.model("decisionnode-flash@acme/fraud")
.build());
d.model(); // "decisionnode-flash@acme/fraud:3"When no worker of decisionnode-moe can take a request at once, the API answers it from
decisionnode-flash instead of making it wait. Such an answer says so: Decision.fallback()
holds servedModel() (the model that answered) and requestedModel() (the model asked for), and
model() is the model that answered. On an ordinary answer fallback() is null.
To get the full model or nothing, set fallback(false): the request is then answered by the model
asked for, or refused with an OverloadedError (a 529), which the client retries like any other.
Decision d = dn.decide(DecideRequest.builder()
.state(ticket)
.question("escalate", truth("Escalate to a human now?"))
.model("decisionnode-moe")
.fallback(false)
.build());fallback is sent only when you set it. decide, the helpers (their longest form takes
fallback after boosters), decideMany and batch requests take it; sessions do not.
A session answers the same questions about a stream of frames over one WebSocket (the JDK's
java.net.http.WebSocket). The server answers the newest frame and skips any that arrived while it
was busy; the next reply lists their seq in dropped().
Session s = dn.openSession(SessionRequest.builder()
.state(Map.of("mission", "Survey the north field"))
.question("abort", truth("Should the mission be aborted now?"))
.build());
s.connect();
s.send(Frame.of(Map.of("battery", 0.31, "wind_mps", 11.4))); // returns the frame's seq: 1
for (SessionEvent e : s.replies()) { // blocks for each event; ends with SessionClosed
if (e instanceof Reply r) {
System.out.println(r.seq() + " " + r.answers().get("abort"));
} else if (e instanceof FrameError f) {
System.out.println("frame " + f.seq() + ": " + f.message());
} else if (e instanceof SessionClosed c) {
System.out.println("closed " + c.code());
}
}
s.end();openSessionalways sends a model (the request's, else the client's). A429session_limit_exceededis never retried and raisesSessionLimitError, aRateLimitError.connect()sends the API key in the handshake'sAuthorizationheader, never in the URL, with the client's own headers. The socket is opened by the client'sHttpClient, so it uses the same proxy andSSLContextas every other call. A session refused at connect closes at once: itsSessionClosedis the first event.sendnumbers frames from 1 unless a frame gives its ownseq, and never waits for a reply. A frame's image follows the inline threshold (a small one inline, a large or cached one by upload id); frames take no URL image.- Replies and frame errors are values;
nextEvent(timeout)andnextEventAsync()read one at a time. Pings are answered automatically. SessionClosed.reopen()is true for the codes after which a new session should be opened (1001,1006,1011,4404,4408,4409,4410). For1013it is false: the session is kept, so connect to it again after a backoff.end()ends the session; one that has already ended is success.
supervise(request, feed, onReply) runs a long-lived feed for you: it opens a session, sends each
reading, hands every reply and frame error to onReply, reopens on the codes above (after a capped
backoff from 1 s to 30 s with jitter; at once for 4408 and 4410), connects again on 1013, and
stops on any other code. When the feed ends it waits for the outstanding replies, ends the session
and returns the last SessionClosed (null when none was seen); when it stops on a code, it returns
that one.
The Batch API answers decisions for less than live calls. The batch price is each model's: models() states it as the model's batchPriceFactor() (DecisionNode MoE 0.7 and DecisionNode Flash 0.5 of the live price; none on a fine-tuned model, which takes no batches), and each line is billed at its own model's, its charge in the line's usage (pricing). A batch has no set finishing time, so poll it; requests still unanswered 24 hours after it is finalized expire, unbilled.
BatchRun run = dn.runBatch(List.of(
BatchRequest.builder("ticket-1").state("I was charged twice.")
.question("refund", truth("Is a refund asked for?")).build(),
BatchRequest.builder("ticket-2").state("How do I export my data?")
.question("refund", truth("Is a refund asked for?")).build()));
try (BatchResults results = run.results()) {
for (BatchResult r : results) {
if (r.isSuccess()) {
System.out.println(r.customId() + " " + r.decision().truth("refund").truth());
} else {
System.out.println(r.customId() + " " + r.status() + " " + r.error());
}
}
}runBatch(requests, pollInterval, timeout) creates an empty draft, adds the requests (in calls of
at most 10 MiB), finalizes it, polls at pollInterval (default 60 s, at least 10 s; the server's
Retry-After when it is longer) until the batch ends, then opens its results. Past timeout
(default 26 hours) it raises TimeoutError. If adding fails, the draft is cancelled and the error
raised.
The calls one by one are on dn.batches(): create, add, finalize, get, list(offset, limit),
results and cancel, each with an ...Async form. results reads the whole file, however many
lines it holds, line by line as you iterate, so a large file is never held whole; the read fails
with TimeoutError when no data arrives for the client's timeout while you wait for the next line,
and a slow reader is never timed out; a line's error is a value (BatchResult.error()) of the class
its status gives, never thrown. create, add and finalize are retried only on 429 and 529,
since a repeat after a lost answer could create a second draft; a finalize answered 409 is
checked with get, and the batch is returned when an earlier finalize went through.
What each API key used and spent over a period, read with a Full access or Manage device keys key
on your server. start and end are RFC 3339 times, rounded to the hour in UTC; a period is at
most 400 days.
// One key: totals, and buckets by hour, day or month when asked.
Map<String, Object> one = dn.usage().key("key_3b8f0d6e2a914c7b9e5f1a2c4d6e8f01",
"2026-10-01T00:00:00Z", "2026-10-03T00:00:00Z", "day", null);
// Every key that matches the filters, one row per key, across all pages.
List<Map<String, Object>> rows = dn.usage().all(
UsageQuery.builder("2026-10-01T00:00:00Z", "2026-11-01T00:00:00Z")
.label("customer", "acme")
.build());key returns the key, the period, as_of, the totals (requests, tokens, booster calls and the
cost) and the buckets or the by_model or by_channel split you asked for. list returns one
page, {period, as_of, items, next_cursor}; all follows next_cursor until it is null and
returns every page's rows. The filters (granularity, groupBy, labels, deviceId, up to 100
keyIds, scope, status, includeEmpty, limit) are checked as the API checks them before
anything is sent.
With a Manage device keys key on your provisioning server, make a Decisions-only key for each device, list them, change one and revoke one or every key a label picks.
Map<String, Object> made = dn.keys().create(KeyCreate.builder("Kiosk 0412")
.deviceId("kiosk-0412")
.label("store", "118")
.dailySpendCap(2.0)
.build());
// made.get("secret") is shown once: hand it to the device and never log it.
List<Map<String, Object>> store = dn.keys().all(KeyListQuery.builder().label("store", "118").build());
dn.keys().update("key_7c2e9a41b0d34f6e8a5c1d2b3e4f5a60", KeyUpdate.builder().clearExpiresAt().build());
dn.keys().revokeByLabels(Map.of("store", "118"), null, true); // a dry run: counts, revokes nothingcreate sends an Idempotency-Key, yours or a new one for the call, the same on every attempt, so
a retried call never makes a second key. list returns one page, {items, next_cursor}, of the
active keys unless status says otherwise; all follows next_cursor until it is null. update
changes only the fields set (a clear... method removes one); a key's device never changes.
revoke revokes one key at once, and revokeByLabels every active key carrying all the labels
given, at most 10,000 a call.
Every decision from the hosted API carries the result of its safety check:
Safety s = d.safety();
s.checked(); // true
s.harm(); // 0.031
s.selfHarm(); // 0.004
s.flagged(); // categories above your key's thresholds, e.g. ["harm"]When the check did not run, checked() is false and reason() says why. A self-hosted server
sends no safety header and safety() is null.
To also get the check in the response body, send the request header
x-decisionnode-safety-body: true. Set it once on the client and it goes with every request:
DecisionNode dn = DecisionNode.builder()
.header("x-decisionnode-safety-body", "true")
.build();
Decision d = dn.decide(state, questions);
d.raw().get("safety"); // the check, as the body carries itA refused request throws RefusalError and is never retried.
Every error the API or the network causes is an unchecked DecisionNodeError, the exported
base class of every error below, so catch (DecisionNodeError e) catches them all. getMessage()
is the server's message (detail.message, or detail when it is a string; "", never null,
when the response carried none), or the client's own description when no response arrived. The error also has status(),
errorType(), requestId(), reason(), the server's explanation in words from the
x-decisionnode-error header, and boosters(), each booster's status and time for a request
that named boosters (empty otherwise).
Only a 2xx response is a success. A 2xx whose body is not JSON is a ServerError.
An error whose body is not JSON (an HTML or text page from a proxy, or an empty body) is classed
by its status alone, as in the table below: a 400 is a BadRequestError and a 403 a
PermissionDeniedError, never their special cases. Its errorType() is null, requestId() and
reason() come from their headers, and the message is the client's own description, for example
HTTP 413 Content Too Large or HTTP 408 Request Timeout (request id req_1). The body is never
copied into the error. Retries are the same as for any other body.
| Status | Error | Extra |
|---|---|---|
| 400 | BadRequestError; ContextLimitError when the input is longer than the model accepts |
|
| 401, or no key | AuthenticationError |
|
| 402 | InsufficientCreditError |
|
| 403 refusal | RefusalError |
category() (harm or self_harm), safety() |
| 403 other (a booster turned off, a suspended workspace) | PermissionDeniedError |
|
| 404 | NotFoundError |
|
| 413, or a body over 25 MiB before sending | RequestTooLargeError |
|
| 422 | ValidationError |
entries() |
| 429 | RateLimitError; SessionLimitError (a subclass) for session_limit_exceeded |
retryAfter() in seconds |
| 529 | OverloadedError |
retryAfter() in seconds |
| other 5xx, a 2xx that is not JSON, or a helper's own answer missing or malformed | ServerError |
|
| any other status, 3xx included | DecisionNodeError itself, with status() |
|
| no response | ConnectionError, TimeoutError |
try {
dn.decide(state, questions);
} catch (RateLimitError e) {
System.out.println("retry in " + e.retryAfter() + " s, request " + e.requestId());
} catch (DecisionNodeError e) {
System.out.println(e.status() + " " + e.reason());
}The async forms complete exceptionally with the same errors.
A request the API would refuse for a fixed, documented rule fails before anything is sent, with
what the API itself would answer: the same class and errorType(), the documented message, and
for a 422 a ValidationError whose entries() are the API's list. A local error has no
status() and no requestId(). The rules are checked in the server's order:
- the 128-level nesting limit (
BadRequestError,api_usage_error, "Invalid request."); - questions: an unknown type, a missing type or criteria, instructions, a state, a description or
a level that is not a string, an object or an array, a score without levels, a number grid that
cannot be built, and a point or box in a request without images, without
imageamong two or more images, naming no image of the request, or withunitsother thanpixelsandnormalized(ValidationError); a choice of no or more than 255 options, a score of more than 10 levels, atruthwith neither instructions nor criteria, a number, point or box question without instructions (BadRequestErrorwith the API's plain message and no error type); - more than 16 images or more than 4 videos (
ValidationErrorwith the API'stoo_longentry on["body", "images"]or["body", "videos"], after the request's other entries; a local image is listed by its id and media type, without its bytes); - images: an empty id, an id longer than 64 characters (counted as Unicode code points, "Image
1: its id is longer than 64 characters."; not a session frame's image, which goes without its
id), a repeated id, bytes that are not JPEG, PNG or WebP or not of
the type given, over 10 MB, over 40 megapixels, an upload reference of another media type
(
api_usage_error); a URL that is nothttps(api_usage_error), or over 2,048 characters, with user information, on a port other than 443 or on a literal private address (image_url_refused); - videos: a video without an id or with an id already used by an image or a video,
a video without frames, a frame time that is negative or not after the one before it, a frame
that is not JPEG, PNG or WebP by its media type,
fpsnot above 0 and at most 4,startorendnegative, andendnot afterstart(api_usage_error); how many frames a request may carry, their sizes and their pixels are the server's to judge; - sessions:
windowoutside 1 to 64 andttl_secondsoutside 1 to 3,600 (ValidationError); a frame without a value or an image, with an empty value, an unknown question id, a URL image or an image that breaks the rules above:sendthrowsBadRequestError, writes nothing and uses noseq; - batches: a missing, empty, too long or repeated
custom_id, anaddwith no requests, andlistpaging outside its bounds (api_usage_error).
Limits that only GET /v1/models states (a model without number questions or sessions, a smaller
max_window, the batch limits) are checked once models() has been called on the client, and
left to the server until then. A request's model finds its entry by name, else by the entry that
states it as its alias or pinned version; a model the list does not state is left to the
server.
The same checks run on their own, without sending or uploading anything, so a request can be validated where it is built:
dn.checkRequest(request); // as decide would send it
dn.checkRequest(sessionRequest); // as openSession would
dn.batches().checkCreateRequest(requests); // as batches().create would; checkAddRequest for add
session.checkFrame(frame); // as session.send would; uses no seqEach returns normally when the request would be sent and otherwise throws exactly what sending
would throw. In decideMany one faulty request fails the whole call and nothing
is sent. Token limits depend on the model and stay the server's to judge.
A problem with your own arguments is the JDK's IllegalArgumentException, thrown before
anything is sent: an empty question map, a value that cannot be serialised to JSON, an image
file that cannot be read, a decideMany concurrency below 1 or an invalid request among those
given to decideMany (nothing is sent), a runBatch poll interval under 10 seconds, and, when
the client is built, a
timeout that is not above 0, a negative maxRetries, a base URL that is not https (see
Configuration), or an HttpClient that follows redirects, is not the JDK's own or turns off TLS
certificate or hostname checks (see Your own HttpClient). These are never wrapped in a
DecisionNodeError.
- Each attempt has a 10 second timeout. It covers the whole attempt, from sending the request to the last byte of the response body, so a response that arrives slowly still ends on time.
- Failed attempts are retried twice on 408, 429, any 5xx (529 included), timeouts and
connection errors. The wait is the server's
retry-after-ms, elseRetry-Afterin seconds or as an HTTP date, capped at 60 s; without one, an exponential backoff from 0.5 s with jitter, capped at 8 s. A decision changes nothing on the server, so a retry is always safe. - A request naming the
ocrbooster (by name or as aBooster) takes longer before the model answers, so it waits 30 seconds per attempt when you set no timeout. A timeout you set is used as given; set at least 30 seconds for such requests. It is never retried after a timeout, because the server finishes the timed-out attempt and may bill it: the client raises theTimeoutError. Its other retries stay. This coversdecide, the helpers and eachdecideManyrequest, not sessions, batches ordecideRaw. - 400, 401, 402, 403, 404 and 422 are never retried.
- Batch
create,addandfinalizeare retried only on 429 and 529, and opening a session is never retried onsession_limit_exceeded; see their sections. - Batch results are read as they stream: the timeout covers the wait for the response to begin, then each wait for more data while you read.
- An upload's storage
PUThas its own timeout,uploadTimeout(60 s for an image and 120 s for a document by default). - A document's add or replace with
waitUntilReady(true)waits 20 seconds per attempt when you set no timeout, since the server holds it up to 10 seconds. - A knowledge 529 whose
details().get("reason")isingestion_delayedis never retried. - Redirects are never followed, so the API key is never sent to another URL or over plain
HTTP. A 3xx is a
DecisionNodeErrorwith its status. AnHttpClientpassed to the builder must not follow redirects either; see below.
DecisionNode dn = DecisionNode.builder()
.apiKey("dn_...") // default: DECISIONNODE_API_KEY
.baseUrl("https://api.decisionnode.com") // default: DECISIONNODE_BASE_URL, else this
.model("decisionnode-flash") // default: DECISIONNODE_MODEL, else decisionnode-moe
.timeout(Duration.ofSeconds(10)) // per attempt, above 0
.maxRetries(2) // 0 or more
.inlineThreshold(524_288) // bytes; local images under it go inline
.uploadTimeout(Duration.ofSeconds(60)) // per storage PUT of an upload, above 0
.header("x-decisionnode-safety-body", "true")
.httpClient(HttpClient.newHttpClient()) // a proxy, an executor, TLS settings; see below
.build();An argument wins over the environment, which wins over the default. A null or empty string counts as not given.
The base URL must be https, so the API key never travels unencrypted. Plain http is
accepted only for a server on the same machine (localhost, 127.0.0.1 or ::1), for example
a self-hosted server during development. Any other http URL, from an argument or from
DECISIONNODE_BASE_URL, makes build() throw IllegalArgumentException. A model can also be set per call with decide(state, questions, images, model), the helpers' model argument or DecideRequest.builder().model(...).
| Model | |
|---|---|
decisionnode-moe |
The most accurate model, always its current release (default) |
decisionnode-flash |
The fast, lower-cost model, always its current release |
decisionnode-<family>-<version> |
One release pinned: models() lists the pinned ids your key may use |
decisionnode-flash@acme/fraud, ...:3 |
Your workspace's fine-tuned models (see Fine-tuned models) |
Every answer's model() is the pinned id of the release that answered, so you can log which
release answered a request sent to a family.
build() checks an HttpClient you pass and throws IllegalArgumentException when:
- it follows redirects. Use
HttpClient.Redirect.NEVER, the JDK default. - it is not the JDK's own client, made by
HttpClient.newBuilder()orHttpClient.newHttpClient(). A subclass can say it never follows redirects and do otherwise, so a client the SDK cannot inspect is treated as one that follows them. - the base URL is
httpsand itsSSLContextdoes not check certificates. A context other than the JDK default is tested once, when the client is built, with an in-memory TLS handshake against a throwaway self-signed certificate; one that accepts that certificate, or does not clearly refuse it, is refused. A context with your own trust store (a company certificate authority, for example) passes.
Hostname checks are on in the JDK's client unless the system property
jdk.internal.httpclient.disableHostnameVerification is set; with it set, a client for an
https base URL is refused, whether you pass an HttpClient or not. Plain http to
localhost, 127.0.0.1 or ::1 uses no TLS, so these TLS checks do not apply to it.
The SDK sends no telemetry and talks to nothing but the base URL and, for an image upload, the
storage URL the API returns for it. Its only added header is
User-Agent: decisionnode-java/<version>. Turn on debug logging for the
com.bynn.decisionnode System.Logger to see the method, path, status, request id and
timing of each attempt; states and questions are never logged.
./gradlew build # unit tests
DECISIONNODE_CONFORMANCE_DIR=/path/to/suite ./gradlew conformanceThe conformance suite is the language-neutral set of cases every DecisionNode SDK passes; it
needs Python 3.10 or later for its mock server. Every case runs twice, through the blocking
methods and through the ...Async methods. Without DECISIONNODE_CONFORMANCE_DIR the
conformance task fails rather than skipping. The suite's mock serves uploads, sessions (a
WebSocket) and batches as well; this SDK passes every case of suite 1.13 in both forms. CI checks the suite out at a pinned commit, set
once as CONFORMANCE_REF in .github/workflows/ci.yml; every action there is pinned to a
commit SHA and no checkout keeps its credentials.
MIT. Copyright (c) 2026 Bynn Intelligence, Inc.