JS / TS SDK
Not on npm yet. For now, install it from the GitHub repo (sdk/js).
Install
Add the package. That's the only dependency.
bun add @decidedb/sdkbun add @decidedb/sdk # or: npm install @decidedb/sdkIt wraps TypeSafe's official Jev client, so you get that too.
Environment variables
Three variables: your Jev key, your DecideDB key, and where DecideDB runs.
DECIDEDB_API_KEY=jdb_live_…TYPESAFE_API_KEY=sk-… # Jev, from console.typesafe.ai
DECIDEDB_API_KEY=jdb_live_… # DecideDB, from your DecideDB console
DECIDEDB_URL=https://<decidedb-cloud-url> # self-hosting: http://localhost:3000Your Jev key only goes to Jev. Your DecideDB key only goes to DecideDB.
DECIDEDB_URL is the DecideDB Cloud URL. Self-hosting? Use http://localhost:3000.
An argument passed to the client always wins over its environment variable.
Quickstart
Make one Jev call through DecideDB. The answer comes back as usual, and DecideDB keeps a copy.
import { DecideDB, choice, noul, score } from "@decidedb/sdk";
const decidedb = new DecideDB({ groupId: "support-bot" });
const response = await decidedb.systemOne({
state: { document: "I was charged twice. Please fix this ASAP." },
questions: {
billing: noul("Is this ticket about billing?"),
tone: choice("What is the customer's tone?", { calm: null, frustrated: null, angry: null }),
urgency: score("How urgent is this ticket?", ["can wait", "this week", "today"]),
},
});
console.log(response.answers.billing.noul);
console.log(response.answers.tone.choice);
console.log(response.answers.urgency.score);
await decidedb.flush();systemOne takes exactly TypeSafe's arguments and returns TypeSafe's response unchanged.
The copy is sent in the background. Your call doesn't wait for it.
Open the console to see the decision.
Set it up once
Create the client in one file. Import it everywhere you call Jev.
src/lib/decidedb.tsimport { DecideDB } from "@decidedb/sdk";
// One client for the whole app. Keys and URL come from .env.
export const decidedb = new DecideDB({ groupId: "support-bot", tags: ["prod"] });Keys, group and tags then live in one place.
Creating a client per request works, but wastes connections. Make one and reuse it.
See the full project tree in where it goes in your project.
Per-call options
Set the group on the client. Pass the user on each call.
userIdimport { decidedb } from "./lib/decidedb";
import { QUESTIONS } from "./questions";
import type { Ticket, User } from "./types";
export async function triage(ticket: Ticket, currentUser: User) {
return decidedb.systemOne(
{ state: { document: ticket.text }, questions: QUESTIONS },
{
userId: currentUser.id, // changes every call
tags: ["prod", "beta-form"], // replaces the client's tags for this call
},
);
}| Option | What it's for | Usually set |
|---|---|---|
| groupId | The feature, team or app the call belongs to. | On the client |
| userId | Who triggered the call. | Per call |
| tags | Free labels, e.g. prod or an experiment arm. | On the client |
A value passed on a call replaces the client's value for that call only.
Request IDs + grading
Every call has an id. Keep it, so you can grade the decision later.
idOf(response)const response = await decidedb.systemOne({ state, questions: QUESTIONS });
const decisionId: string | undefined = decidedb.idOf(response); // a UUID made by the SDK
// Or use your own id, so grading later is just a lookup:
await decidedb.systemOne({ state, questions: QUESTIONS }, { decisionId: ticket.id });Get the id with idOf, or pass your own with decisionId.
Your own id (a ticket or order id) is easiest: you already have it when the outcome is known.
Grading tags a decision good or bad once you know the real outcome. It's coming soon; ids you keep now will work with it.
Errors, flush() and shutdown
DecideDB never breaks your app. Call await flush() before your process exits.
await flush()import { APIError } from "@typesafe-ai/sdk";
try {
const response = await decidedb.systemOne({ state, questions: QUESTIONS });
} catch (err: unknown) { // logged to DecideDB, then re-thrown
if (err instanceof APIError) console.log(err.status, err.body);
}
// On shutdown (scripts, serverless, SIGTERM):
await decidedb.flush();- Jev fails: the failure is logged, then the original error is raised again. Your code behaves exactly as without DecideDB.
- DecideDB is down: nothing breaks. The failed send calls
onError(default: a warning). - Shutdown: logs still in flight are lost if the process exits first. Flush before exiting.
What gets logged
One record per call: the exact request, the exact response, timing and your labels.
{
"id": "0192c0b8-…", // the decision id
"started_at": "2026-09-27T14:02:11Z",
"latency_ms": 184.2,
"status": 200, // Jev's HTTP status
"error": null, // set when the Jev call failed
"user_id": "user_482",
"group_id": "support-bot",
"tags": ["prod"],
"request": { … }, // the exact body sent to Jev
"response": { … }, // the exact body Jev returned
"sdk": "…"
}The DecideDB key travels in the Authorization header. No key is ever in the body.