Documentation

SDK Reference

@kaquill/node — server-side, dependency-free, Node 18+. Four methods. New here? Start with the Quick Start.

Constructor

import { Kaquill } from "@kaquill/node";

const kq = new Kaquill({
  apiKey: process.env.KAQUILL_API_KEY!,
  timeoutMs: 800,
  onError: (err) => logger.warn({ err }, "kaquill"),
});
OptionTypeNotes
apiKeystring, requiredYour workspace key. Starts with kq_. Server-side only.
hoststringDefaults to https://api.kaquill.com.
timeoutMsnumberDefaults to 800. Past this the call fails open and returns null.
defaultUserIdstringUsed when a call is made without a user id. Empty strings count as missing, so `user?.id ?? ""` falls back here.
onError(err: Error) => voidNon-fatal errors. Defaults to console.error outside production.
fetchtypeof fetchInject your own fetch, for tests or a proxy.

identify(userId, traits)

Upserts traits. No decision comes back — it is a profile write. Traits are optional and free-form beyond the documented keys; undefined values are stripped, while null, 0 and false are kept.

await kq.identify("usr_8812", {
  plan: "trial",
  trial_started_at: "2026-09-18T10:00:00Z",
  trial_ends_at: "2026-10-02T10:00:00Z",
  email: "ada@example.com",
  company: "Example Ltd",
  team_size: 12,
});
// -> Promise<void>   (204 from the server)

Documented traits: plan, trial_started_at, trial_ends_at, email, company, team_size.

track(userId, event, properties?)

Records a behavioural event and returns the decision it triggered, if any.

const result = await kq.track("usr_8812", "pricing_viewed", {
  plan_viewed: "growth",
});

result.ok        // false if the call failed - it never throws
result.decision  // Decision | null

convertedToPaid(userId, conversion)

Sends a converted_to_paid event carrying the plan value. discountApplied is what makes the counterfactual answerable later — send it even when it is false.

await kq.convertedToPaid("usr_8812", {
  plan: "growth",
  mrr: 99,
  discountApplied: true,
  metadata: { coupon: "LAUNCH20" },
});
// -> Promise<void>

decide(userId)

Asks for a decision without an event, for questions like “what should this trial user see on the pricing page?”

const decision = await kq.decide("usr_8812");
// -> Decision | null

The Decision object

Returned verbatim from the API. DoNothing is never a decision — you get null instead.

interface Decision {
  action_name: string;   // OfferDiscount20 | OfferTrialExtension
                         // | ShowUpgradeCTA | ShowFeatureNudge
                         // | SendSlackAlert
  action_id: number;     // 1-5
  confidence: number;    // 0.0-1.0
  logic_path: string;    // e.g. "AI_TREATMENT"
  copy?: {
    headline: string | null;
    body: string | null;
    cta: string | null;
    source: string;
  };
}
Confidence is not a conversion probability. It is the policy’s confidence in the action it chose. It is also not the counterfactual that Ghost MRR is weighted by.

The outbound webhook envelope describes the same decision with different field names (action, headline, body). If you consume both, see Webhooks & delivery.

Failure behaviour

  • Nothing throws into your code path. Failures resolve to { ok: false, decision: null } or null.
  • Requests abort at timeoutMs (800 by default), so a slow KaQuill cannot slow your app down.
  • A missing user id is reported through onError and the call is skipped rather than sent.