JS / TS SDK

Not on npm yet. For now, install it from the GitHub repo (sdk/js).

Install

TL;DR

Add the package. That's the only dependency.

bun add @decidedb/sdk
terminal
bun add @decidedb/sdk   # or: npm install @decidedb/sdk

It wraps TypeSafe's official Jev client, so you get that too.

Environment variables

TL;DR

Three variables: your Jev key, your DecideDB key, and where DecideDB runs.

DECIDEDB_API_KEY=jdb_live_…
.env
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:3000

Your 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

TL;DR

Make one Jev call through DecideDB. The answer comes back as usual, and DecideDB keeps a copy.

main.ts
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

TL;DR

Create the client in one file. Import it everywhere you call Jev.

src/lib/decidedb.ts
src/lib/decidedb.ts
import { 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

TL;DR

Set the group on the client. Pass the user on each call.

userId
src/tickets.ts
import { 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
    },
  );
}
OptionWhat it's forUsually set
groupIdThe feature, team or app the call belongs to.On the client
userIdWho triggered the call.Per call
tagsFree 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

TL;DR

Every call has an id. Keep it, so you can grade the decision later.

idOf(response)
src/tickets.ts
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

TL;DR

DecideDB never breaks your app. Call await flush() before your process exits.

await flush()
src/tickets.ts
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

TL;DR

One record per call: the exact request, the exact response, timing and your labels.

record.json
{
  "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.