TypeScript SDK

The SDK is open source: github.com/llmjury/llmjury-typescript (Apache-2.0 — issues and PRs welcome).

Install from npm (Node 18+ or the browser; dual ESM + CJS; zero runtime dependencies — the transport is fetch):

npm install llmjury-sdk

Set up your API key

The client won't start without your org's publishable key (llmj_pk_…) — grab it from the dashboard under Settings → API keys (admins can view it there any time). Then either:

Recommended in Node — set the environment variable. The client reads LLMJURY_API_KEY automatically, so the key never lives in your code:

export LLMJURY_API_KEY=llmj_pk_...   # shell / CI secret / deployment env / .env file
import { Client } from 'llmjury-sdk';

const client = new Client(); // picks up LLMJURY_API_KEY — nothing else to configure

In the browser (or anywhere env vars don't reach) — pass it explicitly:

const client = new Client({ apiKey: 'llmj_pk_...' });

The publishable key is safe in client-side code: write-only and rate-limited. If no key is found, the constructor throws immediately with a message saying exactly that — you can't accidentally run unauthenticated. The key is sent as X-API-Key; the SDK defaults to the production API (LLMJURY_BASE_URL/baseUrl override for a local stack).

Quickstart

import { Client } from 'llmjury-sdk';

// ---- setup, once at startup -------------------------------------------------
// new Client() needs your publishable API key (dashboard -> Settings -> API keys).
// Easiest (Node): `export LLMJURY_API_KEY=llmj_pk_...` — it's read automatically.
// In the browser, pass it in code: new Client({ apiKey: 'llmj_pk_...', ... })
const client = new Client({ experiments: ['checkout-prompt'] }); // prefetch by experiment NAME
const llm = client.wrap(anthropicClient, 'checkout-prompt'); // every model call is now traced

// ---- per request ------------------------------------------------------------
await client.withUser(userId, async () => {
  // Variant prompt from client memory; your in-code default survives an outage.
  const p = client.getPrompt('checkout-prompt', userId, 'You are a helpful assistant.');
  // Call your provider client DIRECTLY — latency/tokens/errors are intercepted.
  const response = await llm.messages.create({
    model: 'claude-haiku-4-5',
    max_tokens: 1024,
    system: p.prompt,
    messages: [{ role: 'user', content: userInput }],
  });

  // The ONLY explicit metric: your business outcome.
  client.track('business_event', {
    experiment_id: 'checkout-prompt',
    user_id: userId,
    variant: p.variant,
    business_metric: 'conversion',
    value: 1,
  });
  return response;
});

What the client does

  • getPrompt(experiment, user, defaultPrompt) — the recommended entrypoint: resolves the user straight to their variant's prompt text. Your in-code default is returned whenever the config isn't cached, assignment fails, or the variant has no prompt — the app always has a working prompt, even during a full LLMJury outage. Returns { variant, prompt, fallback }.
  • getVariables(experiment, user, defaults) — same idea for custom variables (model, temperature, …): the variant's configured values merged over your in-code defaults, from client memory — never an API call on the request path.
  • wrap(providerClient, experiment) + withUser(user, fn) — setup-once interception: wrap your OpenAI/Anthropic-style client at startup, bind the user per request with await client.withUser(userId, async () => { … }), then call the provider directly. Every model call records the exposure plus a model_call event with measured latency, tokens, model, and errors — no call-site code. (interceptModelCall is the explicit form.)
  • assign(experiment, user) — the low-level call: deterministic local bucketing (the exact same MurmurHash3 result as Python and Java); returns the variant key or null until the config loads — treat null as "use control".
  • track(event, payload) — enqueue-only with a timer-driven flush, bounded retries, then an offline spill (FileOfflineStore in Node, IndexedDbOfflineStore in the browser; 24h replay with original timestamps) or a logged drop. Never throws into your app. With wrap(), the only event you track by hand is your business_event.
  • Experiments are addressed by unique name or id — names resolve through the polled config and bucketing always runs on the canonical id, so both address forms assign identically.
  • Config is polled from GET /v1/config (ETag/304, 60s) with an immediate refresh on a newer ingest-ack config_version. await client.ready() resolves once prefetched configs are loaded.

Full details in the SDK README.