SDKs & CLI
TypeScript SDK
The official TypeScript and JavaScript library for Wity. You give it some text and a few questions, and it gives back a typed answer for each question, with a probability for every option.
The SDK handles the parts of calling an API that are easy to get wrong:
- It sends your API key, and keeps it out of logs and error messages.
- It retries requests that failed for a passing reason, like a busy server.
- It turns API errors into error classes you can check with
instanceof. - It types every answer from your questions, so your editor knows the option ids of a
choice.
Install#
npm install wity-sdk
- Needs Node.js 20 or newer. It runs on your server, not in the browser (see Security).
- It has no other dependencies.
- It works with both
importandrequire:const { WityClient } = require("wity-sdk");
API key#
Get an API key from the keys page. Save it in your environment variables as WITY_API_KEY, then pass it to the client:
import { WityClient } from "wity-sdk";const client = new WityClient({ apiKey: process.env.WITY_API_KEY });
You can pass any key as apiKey, for example one from a secret manager. If you leave apiKey out, the client reads WITY_API_KEY by itself.
Your first request#
This example reads a customer's message and asks Wity three questions about it: which team should handle it, whether it's urgent, and how much it hurts the customer. All three are answered in one request.
import { WityClient, choice, noul, score } from "wity-sdk";const client = new WityClient({ apiKey: process.env.WITY_API_KEY });// The text you want Wity to judge.const ticket = "I was charged twice for my subscription this month. Please refund one of them today.";const res = await client.systemOne({state: ticket,questions: {// Pick one of these teams.team: choice("Which team should handle this ticket?", {billing: "Payments, charges and refunds",technical: "Bugs, errors and outages",other: "Anything else",}),// Yes or no.urgent: noul("Does the customer ask for something to happen soon?", {true: "They ask for fast action",false: "No time pressure",}),// A level on a scale, from lowest to highest.severity: score("How much is this hurting the customer?", ["Not at all", "A little", "A lot"]),},});console.log(res.answers.team.choice); // billingconsole.log(res.answers.urgent.noul); // 0.99989...console.log(res.answers.severity.score); // 1.97322...
The examples on this page use await at the top level, which works in ES modules (.mjs files, or "type": "module" in package.json). Elsewhere, put the code inside an async function.
Every call to systemOne has this syntax. Words in <angle brackets> are placeholders for your own values:
const res = await client.systemOne({state: <the text to judge>, // required: a string, object or arrayquestions: { <name>: <question>, ... }, // required: one or more questionsreasoning: <"auto", "off" or "always">, // optional. Default: "auto"max_latency_ms: <200 to 120000>, // optional: a time limit in msimage: <data URL>, // optional: one image});
The sections below go through each part step by step.
1. Create a client
WityClient holds your key and settings. Create one when your app starts and use it for every request. There's nothing to close.
2. Pass the state
state is what you want Wity to judge: an email, a support ticket, a log line, a product review. It can be a string, or an object or array, which Wity reads as JSON. It can be up to 32,000 characters long.
state: "<some text>" // a stringstate: { <field>: <value>, ... } // an object, read as JSONstate: [<item>, ...] // an array, read as JSON
3. Write the questions
questions is an object. Each key is a name you choose, like team. You use that name later to find the answer.
questions: {<name>: <question>, // a name you choose, then a question built with choice, noul or score<name>: <question>, // add as many questions as you need}
Each value is a question, built with one of three helpers:
billing) and a description ("Payments, charges and refunds"). Wity reads the description to understand the option, and answers with the id.true and false descriptions are optional. They say what yes and no mean in your case, which helps when the question alone is vague.Each helper takes the question first. Write it the way you'd ask a colleague. See Writing good questions for tips.
4. Read the answers
systemOne sends the request and returns a promise of the response. res.answers files each answer under the name you gave its question:
const { team, urgent, severity } = res.answers;team.choice; // "billing": the option with the highest probabilityteam.probabilities; // { billing: 0.99993..., technical: 0.00003..., other: 0.00003... }urgent.noul; // 0.99989...: the probability that the answer is yesseverity.score; // 1.97322...: the expected level. 0 is "Not at all" and 2 is "A lot",// so this is very close to "A lot"
Each answer has the type that matches its question. TypeScript knows team.choice can only be "billing", "technical" or "other", so a typo in an option id is caught before your code runs.
Acting on an answer#
The probabilities let your code decide when to act on its own and when to ask a person. A common pattern: act when the top option is very likely, and send everything else to a person.
const { team, urgent } = res.answers;if (team.probabilities[team.choice] >= 0.9) {await assignToTeam(team.choice, ticket); // Wity is sure: route it automatically} else {await sendToReview(ticket); // not sure enough: a person decides}if (urgent.noul >= 0.8) {await markUrgent(ticket);}
Probabilities add up to 1, and a higher number means Wity is more sure. The 0.9 above is only an example. Pick thresholds that suit your data. See Evaluating on your data and Probabilities & confidence.
Question types#
A closer look at each helper, with what its answer contains. The numbers in these examples are illustrations.
choice: pick one option
choice("<question>", {<id>: "<description>", // one line per option<id>: "<description>",})
const question = choice("What is this email about?", {invoice: "A bill or payment request",meeting: "Planning or moving a meeting",spam: "Unwanted advertising or a scam",});const res = await client.systemOne({ state: email, questions: { topic: question } });const topic = res.answers.topic;topic.choice; // "invoice" (typed as "invoice" | "meeting" | "spam")topic.probabilities; // { invoice: 0.94, meeting: 0.04, spam: 0.02 }topic.probabilities.spam; // the probability of any option, not only the winner
probabilities.Option ids written as numbers ({ 1: "Ground" }) come back as strings ("1"), and are typed that way.
noul: yes or no
// without descriptionsnoul("<question>")// with descriptions of yes and no (give both)noul("<question>", { true: "<what yes means>", false: "<what no means>" })
const question = noul("Is this message trying to steal a password?", {true: "It asks for a password, a login or a code", // optional: what yes means herefalse: "It doesn't ask for any login details", // optional: what no means here});const res = await client.systemOne({ state: email, questions: { phishing: question } });res.answers.phishing.noul; // 0.97: the probability of yes. The probability of no is 1 - 0.97
noul >= 0.8.score: a level on a scale
score("<question>", ["<lowest level>", "<next level>", ..., "<highest level>", // 2 to 10 levels, lowest first])
const question = score("How angry is the customer?", ["Calm", // level 0: the lowest"Annoyed", // level 1"Angry", // level 2: the highest]);const res = await client.systemOne({ state: ticket, questions: { anger: question } });const anger = res.answers.anger;anger.score; // 1.42: the expected level, between "Annoyed" (1) and "Angry" (2)anger.probabilities; // { "0": 0.07, "1": 0.44, "2": 0.49 }: one probability per levelanger.legend; // { "0": "Calm", "1": "Annoyed", "2": "Angry" }// The single most likely level, as text:const [best] = Object.entries(anger.probabilities).sort((a, b) => b[1] - a[1])[0];anger.legend[best]; // "Angry"
"0", "1", …).choice.The levels can come from a variable, like a list loaded from a database.
Limits
BadRequestError that names it, and the call isn't billed. See Errors, limits & billing.Reasoning#
Most questions are clear, and Wity answers them in one quick pass, in about a tenth of a second. Some are harder: two options are almost equally likely, or the question asks how likely something is. For those, Wity first thinks step by step, which usually takes 1 to 4 seconds. Thinking isn't billed.
You choose when Wity may think with reasoning. Pass it to systemOne, next to state and questions. It applies to every question in the request.
reasoning: "auto" // think only when the question needs it (the default)reasoning: "off" // never thinkreasoning: "always" // think before every answer
const res = await client.systemOne({state: ticket,questions,reasoning: "off", // "auto" (the default), "off" or "always"max_latency_ms: 3000, // stop thinking after 3 seconds and answer with what it has});
max_latency_ms caps how long one request may take, from 200 to 120000 ms. When the time runs out, Wity stops thinking and answers with what it has. The SDK waits at least max_latency_ms plus 10 seconds before it gives up, even if timeoutMs is shorter.
Each answer says whether Wity thought:
const team = res.answers.team;team.reasoning?.thought; // true if Wity thought before answeringteam.reasoning?.reason; // why it thought, like "close_call". null if it didn't thinkteam.reasoning?.budget_limited; // true if max_latency_ms cut the thinking shortteam.direct_probabilities; // the answer before thinking. Missing if it didn't think
With reasoning: "off", reasoning on the answer is missing. For a noul, the answer before thinking is in direct_noul. More in Reasoning & auto mode.
Images#
A request can include one image, like a photo to check. Send it as a data URL: the image type, then the file's bytes encoded as base64 text.
import { readFileSync } from "node:fs";// Turn the image file into a data URL: its type, then its bytes in base64.const image = "data:image/jpeg;base64," + readFileSync("parcel.jpg").toString("base64");const res = await client.systemOne({state: "Photo of the parcel, taken by the driver at delivery",questions: { damaged: noul("Is the parcel damaged?") },image,});
- Use
image/pnginstead ofimage/jpegfor a PNG file. stateis still required. Use it to say what the image shows, or to add text that goes with it.generatetakes an image the same way. See Images.
Generate#
Questions pick from options you wrote in advance. generate writes new text instead: a value read off a document, a form field, a one-line reply. It returns one piece of text per call, with no probabilities. If the answer is one of a known set, ask a question instead: you get probabilities, and the answer is easier to check.
Free text
const reply = await client.generate({state: ticket,instructions: "Write the one-sentence reply the agent sends to the customer.",max_tokens: 80, // the longest the answer may be. 1 to 512, default 128});reply.text; // "Thank you for bringing this to our attention; I've reviewed your account..."
instructions says what to write. max_tokens caps the length. A token is a word or a piece of a word.
JSON in a shape you define
Pass a shape to get JSON back instead of free text. The shape is a JSON Schema, the standard way to describe JSON: which fields it has, and what each one holds. Its top level must be an object.
const origin = await client.generate<{ city: string }>({state: "Ticket scan: LISBOA (LIS) -> BERLIN BRANDENBURG (BER), 14 Oct, seat 23C.",instructions: "Origin city name, in English",// A JSON Schema: the answer must be an object with a "city" field// holding 1 to 40 letters or spaces.shape: {type: "object",properties: { city: { type: "string", pattern: "^[A-Za-z ]{1,40}$" } },required: ["city"],},max_tokens: 40,});if (origin.finish_reason === "stop") {origin.value?.city; // "Lisbon": the JSON, already parsed} else {// "length": Wity ran out of max_tokens. The JSON is incomplete and value is null.await sendToReview(ticket);}
finish_reasonis"stop"when Wity finished. The JSON then matches your shape, andvalueholds it, already parsed.finish_reasonis"length"when Wity hitmax_tokensfirst.textis cut off, andvalueisnull. Raisemax_tokens, or send the item to a person.generate<{ city: string }>tells TypeScript whatvaluelooks like. Keep it in line with yourshape: the SDK doesn't check that they match.textalways holds the raw output, as a string.
Heads up
state.Many requests at once#
Every call returns a promise, so you can send several requests at the same time with Promise.all:
const tickets = ["The site has been down for an hour and we're losing sales.","Could you add a dark mode some day?",];// Send one request per ticket, all at the same time, and wait for all of them.const results = await Promise.all(tickets.map((t) => client.systemOne({ state: t, questions: { urgent: noul("Is this urgent?") } })),);results.forEach((res, i) => console.log(res.answers.urgent.noul.toFixed(2), tickets[i]));
Promise.allreturns the results in the same order astickets.- Each key allows a limited number of requests at a time (see limits). The SDK retries when you go over, but for large batches, send them in smaller groups.
Errors#
When a request fails, the SDK throws an error. Every one of them extends WityError, so err instanceof WityError matches them all. Check for the specific ones you want to handle differently:
import { APIError, AuthenticationError, BadRequestError, RateLimitError, WityError } from "wity-sdk";try {const res = await client.systemOne({ state: ticket, questions });} catch (err) {if (err instanceof BadRequestError) {console.error(err.message); // the request is wrong, and the message says which part. Fix it: retrying won't help} else if (err instanceof AuthenticationError) {// the key is missing, wrong or revoked} else if (err instanceof RateLimitError) {// still too many requests after the SDK's own retries. Try again later} else if (err instanceof APIError) {console.error(err.status, err.requestId); // any other error from the API. Quote requestId when you report it} else if (err instanceof WityError) {console.error(err.message); // anything else, like no connection or no key set} else {throw err; // not from the SDK}}
status, body, headers and requestId, the server's id for the request. Quote requestId when you report a problem.signal (see below).Unknown fields
A field the SDK doesn't know, like a typo (max_latency instead of max_latency_ms), is still sent. That way, new API fields work without waiting for an SDK update. The SDK logs a warning that names the field, and if Wity rejects the request, the BadRequestError names it too.
Retries, timeouts and cancelling#
The SDK retries some failed requests by itself, so your code doesn't have to:
- It retries 408, 429 and 5xx errors and network failures, up to 2 times. It waits a little longer before each retry, or as long as the API's
Retry-Afterheader asks. - If
Retry-Afterasks for more than 60 seconds, the SDK throws the error straight away, so you can decide what to do. - It doesn't retry timeouts. The request may already have reached Wity, and you've already waited the full time.
- If the connection breaks while the answer is arriving, Wity may already have billed the call. The retry is billed again.
You can change both settings when you create the client:
const client = new WityClient({apiKey: process.env.WITY_API_KEY,timeoutMs: 30_000, // give up on one attempt after 30 seconds. Default: 60000maxRetries: 0, // don't retry failed requests. Default: 2});
To cancel a call, pass an AbortSignal as the second argument. Cancelling also stops any retries. AbortSignal.timeout(ms) gives a signal that cancels after that time, which caps the total time across all attempts:
// give up after 20 seconds in totalawait client.systemOne({ state: ticket, questions }, { signal: AbortSignal.timeout(20_000) });
Logging#
By default the SDK only logs warnings, like an unknown field in a request. When you're debugging, turn on more detail:
new WityClient({ logLevel: "debug" }); // or set WITY_LOG_LEVEL=debugnew WityClient({ logLevel: "off" }); // nothing at allnew WityClient({ logger: pino() }); // your own logger: anything with debug, info, warn and error
Retry-After too long to wait for. Nothing when calls succeed.- Logs go to the console with a
[wity]prefix, unless you pass your ownlogger. - Logs never contain your API key or the
statetext, which is often customer data. - Errors are always thrown, whatever the level. Logs only add detail.
Configuration#
Everything you can pass when you create a client:
WITY_API_KEY.WITY_BASE_URL, or the production API. Only needed for testing against another server. client.baseURL shows the one in use.60000.2.WITY_LOG_LEVEL, or "warn".debug, info, warn and error methods. Default: the console.fetch, for example to add a proxy. Default: the global fetch.Security#
- The SDK runs on your server.
WityClientdoesn't run in a browser page or worker. - Test setups that act like a browser, such as jsdom, count as a browser. Run tests that create a
WityClientin a Node environment. - The client never shows the key when it's printed, logged or turned into JSON, and errors never include it.
baseURLmust usehttps://. Plainhttp://is allowed only forlocalhost,127.0.0.1and[::1].- The client never follows redirects, so the key only goes to the address you set.
- An answer larger than 10 MB can't be from Wity. The client stops reading it and throws a
WityError.
Questions, bugs and security problems: info@alphanimble.com. Please report security problems by email, not in public. The SDK is licensed Apache-2.0.
