This article is auto-synced from its in-app version in Tai.
Coming soon. This feature isn't available to your company yet.
The Tai Guard SDK (@nezasa/tai-guard) checks a trip with Tai Guard from your booking checkout and shows the risks it finds — tight connections, missing traveller data, entry rules, disruptions at the destination — next to the booking, before it is confirmed.
When you'd use this
Use it when your own checkout page should warn about risks in the itinerary being booked. For example: a traveller is about to book a trip with a 40-minute connection in Lisbon; the checkout shows "Tight connection in Lisbon" with what to do about it, while there is still time to change the flight.
The SDK runs in the browser and calls TAI directly. Your server never forwards the itinerary to TAI; it only issues a short-lived token that works for that one itinerary, in that one browser.
Steps
1. Create an OAuth client for the checkout
A Company Admin creates one OAuth client per company that runs the checkout:
In the Workbench sidebar, click Settings, then API Keys under Users & Access.
Click New API key or OAuth client and choose the OAuth client type.
Set Allowed agents to Tai Guard, Allowed channels to REST API and Permissions to Invoke agents.
Set Allowed Referers to your checkout's origins, for example
https://shop.example.com/, each with the trailing slash: a referer matches by prefix, so without ithttps://shop.example.com.evil.examplewould match too. Leave Allowed IPs empty: the browser calls TAI from each shopper's own address.Click Create OAuth Client and copy the Client ID and the client secret. The secret is shown once.
Keep the client secret on your server. It must never reach the browser. Use this client for the checkout only: a server that calls agents itself sends no referer, so it needs a client of its own.
See API keys for rotating the secret and editing restrictions.
2. Issue a token from your server
Add an endpoint to your checkout's backend that the checkout page calls with its key's thumbprint (see step 4). It:
Serializes the itinerary as JSON — the exact string the browser will send.
Computes the SHA-256 of that string's UTF-8 bytes, as lowercase hex.
Requests a token with
grant_type=client_credentials, the hash asitinerary_hash, your checkout session's id assid, the page's key thumbprint asdpop_jkt, and — optionally — your own key for the trip ascache_key.Checks the token response:
itinerary_boundmust betrue,token_typemust beDPoP,sidmust equal the session id it sent andcache_keythe key it sent (nullwhen it sent none). Otherwise it refuses the token and returns an error to the page — never hand the page a token that came back unbound.Returns the token and the itinerary string to the page.
The token works only for an itinerary whose bytes hash to itinerary_hash, and only on the calls the SDK makes. With dpop_jkt, it also works only in the browser that holds that key: the SDK signs each call with the key, so a token copied out of the page is refused anywhere else. TAI answers such a token with token_type DPoP. It expires after 15 minutes. Reuse one token for a session while the itinerary and the thumbprint stay the same; request a new one when either changes or the token is about to expire.
cache_key says which checks are "the same trip": a check with the same cache_key, language (the one it is checked in, so hi and en count as one) and audience within 7 days answers from the earlier check instead of starting — and paying for — a new one, even when the itinerary JSON changed in a way that doesn't matter (a refreshed price, a timestamp). After a change to your company's Risk Catalogue settings that grades trips differently, the next check starts afresh. Pick a key that changes whenever the trip does, such as the booking's id and its version: trip-4711:12. It is 1 to 200 characters of letters, digits and . _ : ~ -. Without one, Tai Guard compares the trip as it reads it — its services, dates, places and travellers — and ignores the rest of the JSON.
// Node.js
import { createHash } from "node:crypto";// tripKey is optional, e.g. "trip-4711:12"
export async function taiGuardToken(itinerary, checkoutSessionId, keyThumbprint, tripKey = null) {
const itineraryJson = JSON.stringify(itinerary);
const itineraryHash = createHash("sha256").update(itineraryJson, "utf8").digest("hex");
const credentials = Buffer.from(
`${process.env.TAI_CLIENT_ID}:${process.env.TAI_CLIENT_SECRET}`,
).toString("base64");
const form = new URLSearchParams({
grant_type: "client_credentials",
scope: "agent:execute",
itinerary_hash: itineraryHash,
sid: checkoutSessionId,
dpop_jkt: keyThumbprint,
});
if (tripKey) form.set("cache_key", tripKey); const response = await fetch("https://taitravel.app/api/v1/oauth/token", {
method: "POST",
headers: {
Authorization: `Basic ${credentials}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: form,
});
if (!response.ok) throw new Error(`TAI token request failed: ${response.status}`);
const body = await response.json();
// Never hand the page a token that isn't bound to this itinerary and session.
if (
body.itinerary_bound !== true ||
body.token_type !== "DPoP" ||
body.sid !== checkoutSessionId ||
body.cache_key !== (tripKey || null)
) {
throw new Error("TAI returned a token that is not bound to this checkout");
}
return { token: body.access_token, itineraryJson };
}
The same request with curl:
ITINERARY_HASH=$(printf '%s' "$ITINERARY_JSON" | shasum -a 256 | cut -d' ' -f1)curl https://taitravel.app/api/v1/oauth/token \
-u "$TAI_CLIENT_ID:$TAI_CLIENT_SECRET" \
-d grant_type=client_credentials \
-d scope=agent:execute \
-d itinerary_hash="$ITINERARY_HASH" \
-d sid="$CHECKOUT_SESSION_ID" \
-d dpop_jkt="$KEY_THUMBPRINT" \
${TRIP_KEY:+-d cache_key="$TRIP_KEY"}
Before passing the token on, check that the response says "itinerary_bound": true and "token_type": "DPoP", and repeats your sid and cache_key.
dpop_jkt is the 43-character value getKeyThumbprint() returns; pass it on unchanged. sid is 1 to 128 characters of letters, digits and . _ : @ -. Rate limits apply per session, so one busy checkout session can't use up another's allowance; your company's overall limit still covers them all.
3. Add the SDK to your checkout
From npm. The package publishes to GitHub Packages under the @nezasa scope, like the TAI Chat SDK — point the scope at that registry in your .npmrc with a token that can read packages:
@nezasa:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
npm install @nezasa/tai-guard
As a script. Copy dist/tai-guard.iife.js from the package (it is also exported as @nezasa/tai-guard/script) to your own web server and load it with a plain <script> tag. It defines window.TaiGuard, with the same check, start, getKeyThumbprint, resolveLanguage and toHtml functions. It has no dependencies.
4. Check the itinerary
import { check, getKeyThumbprint } from "@nezasa/tai-guard";const keyThumbprint = await getKeyThumbprint();
const { token, itineraryJson } = await fetch("/checkout/tai-guard-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keyThumbprint }),
}).then((r) => r.json());const findings = await check({
token,
itineraryJson,
sender: "tripbuilder",
language: "de",
});
Get the thumbprint first, then the token, on every page load. getKeyThumbprint() makes the browser's key the first time and keeps it for your checkout's origin, so it usually returns the same value; its private half can't be read out of the browser. If a token is bound to a key this browser doesn't hold, check() rejects with DPOP_KEY_MISMATCH before sending anything.
check() resolves to the list of findings, already written in the language Tai Guard picked for language. An itinerary with no services to check resolves to an empty list. Pass itineraryJson exactly as your server returned it: the SDK sends it unchanged, so the hash still matches.
Pass your shopper's own language; you don't need to know which ones Tai Guard writes in. It writes in the closest one: de-CH in German, nb-NO in Norwegian, and a language it doesn't write in, such as hi, in English, with one warning in the browser console. A check that ends up in English is the same check as one asked in English, so it isn't paid for twice.
A check usually takes several seconds. Show a placeholder while it runs, and don't hold the booking on it.
On a checkout of several pages, start the check early and collect it where you show the findings. Call start() on the first checkout page: it takes the same options as check(), returns as soon as TAI has accepted the check, and leaves it running when the page is left. Call check() with the same options on the page that shows the findings, even after a full page load: it picks up the running or finished check instead of starting another.
// First checkout page, after the thumbprint and token above
await TaiGuard.start({ token, itineraryJson, sender: "tripbuilder", language: "de" });// Any later page: fetch its thumbprint and a token first, as on every page,
// then pass the same options
const findings = await TaiGuard.check({ token, itineraryJson, sender: "tripbuilder", language: "de" });
If the itinerary JSON changes between the two pages but the trip doesn't (a refreshed price, a timestamp), issue a new token for it with the same cache_key: check() still picks up the check start() began. When the trip itself changes — a night added, a flight swapped — change the cache_key too, so check() starts a new check of the trip as it is now.
5. Show the findings
Render them with toHtml:
import { toHtml } from "@nezasa/tai-guard";document.querySelector("#trip-risks").replaceChildren(
toHtml(findings, {
language: "de",
componentHref: (component) => `#service-${component.service_id}`,
}),
);
toHtml returns the findings in groups, Important first (or by service type or by place, with groupBy). Each card shows the title and the detail, the services it affects (linked when componentHref returns a URL), and when and where it applies; grouped by service type or by place, it also shows how much it matters (Important, Worth Checking, Good to Know). An Affects line with two or more services lists them as bullets. A card says each thing once: under a severity heading it carries no severity badge, a finding about one service its text already names gets no separate Affects line, a date shown once isn't repeated, a place its location heading already names isn't repeated, and the source is named only when it is something other than your itinerary data. It adds its stylesheet to the page once. Or build your own view from the Finding objects — see the Reference below.
6. Handle errors
check() rejects with a TaiGuardError. Test for one with isTaiGuardError(error) and branch on error.code:
Code | What it means | What to do |
| The itinerary sent is not the one the token was issued for | Pass the exact string your server hashed; request a new token when the itinerary changes |
| Tai Guard could not read the itinerary | Check that |
| The token is missing, expired or revoked, or the page's origin isn't in Allowed Referers | Request a new token; check the OAuth client's referers |
| The token is bound to a key this browser doesn't hold — the thumbprint came from another browser (or another tab, where the browser can't store the key), or the site's data was cleared since; nothing was sent | Request a new token with the thumbprint |
| TAI refused the signature the SDK sent with the call; |
|
| This session, or your company as a whole, made too many calls | Wait |
| Your company's credit budget for the period is used up | Hide the check until a Company Admin raises the budget |
| Tai Guard's run timed out, never started or failed on TAI's side | Offer to check again — a repeated check starts a new run |
| TAI refused the request's parameters, or the itinerary is over 2 MB | Check |
| No answer within | Offer to check again — the run keeps going and the next check picks it up |
| Your | — |
| TAI could not be reached, even after one retry | Offer to check again |
| The check's run failed or was cancelled without naming a reason |
|
| An option is invalid; nothing was sent | Fix the call |
| TAI's answer was not what the SDK expects | Report it with |
| The page is not served over | Serve the checkout over |
Other codes from the Agent REST API error table pass through unchanged. error.status holds the HTTP status, error.runId the run when one was started, and error.requestId the id to quote when you contact your Nezasa representative.
Example: a TripBuilder checkout
A checkout built on TripBuilder sends TripBuilder's own itinerary JSON with sender: "tripbuilder". Its backend issues the token with the checkout's session id, the page's key thumbprint and no cache_key, so Tai Guard recognizes a repeat check of the trip by its services, dates, places and travellers; the page checks in the traveller's language and links each affected service to its row in the booking summary:
<div id="trip-risks" aria-live="polite"></div>
<script src="/assets/tai-guard.iife.js"></script>
<script type="module">
try {
const keyThumbprint = await TaiGuard.getKeyThumbprint();
const { token, itineraryJson } = await fetch("/checkout/tai-guard-token", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keyThumbprint }),
}).then((r) => r.json());
const findings = await TaiGuard.check({
token,
itineraryJson,
sender: "tripbuilder",
language: "de-informal",
});
document.querySelector("#trip-risks").replaceChildren(
TaiGuard.toHtml(findings, {
language: "de-informal",
componentHref: (component) =>
component.component_id ? `#component-${component.component_id}` : null,
}),
);
} catch (error) {
console.warn("Tai Guard check failed", error.code, error.requestId);
}
</script>
Reference
check(options)
Option | Type | Description |
| string | The token your server issued (required) |
| string | The itinerary JSON, exactly as hashed for the token, with no whitespace before or after it (required) |
| string | The system the itinerary comes from, e.g. |
| string | The language of the findings (required), such as your shopper's |
| string | Who the findings are for: |
|
| Aborts the check |
| number | Gives up after this many milliseconds; default |
| string | The TAI to call; default |
| boolean |
|
check() resolves to the list of findings, which also carries two read-only properties:
Property | Type | Description |
| string | null | When the check that produced the findings finished, as an ISO 8601 timestamp — an earlier check's time when that one answered. |
| boolean |
|
Read them off the result itself: a copy of the list — filter, map, or [...findings] — keeps the findings only. Your code that treats the result as a plain list of findings keeps working. The result type is CheckResult.
getKeyThumbprint()
Resolves to the thumbprint of this browser's key (a 43-character string), for your server to pass to the token endpoint as dpop_jkt. The SDK makes the key on the first call and keeps it in the browser's storage for your checkout's origin; later calls, and later page loads, return the same value. Where the browser can't store it, for example in some private windows, the key lasts only for that page load, and the SDK says so once in the browser console. Rejects with CRYPTO_UNAVAILABLE when the page is not served over https.
start(options)
Takes the same options as check(), refresh included. Resolves to { runId, findings, checkedAt, cached } as soon as TAI has accepted the check: runId is the check's run, and findings is the list when TAI already had it (an earlier check of the same trip) and null while the check is still running. checkedAt and cached say what they say on check()'s result; checkedAt is null while findings is. It never waits for the findings and never cancels the check. It rejects with a TaiGuardError like check() does — also when an earlier check of the same trip already failed.
toHtml(findings, options)
Option | Type | Description |
| string | The language of the labels — pass the one you checked in (required). Resolved as |
| string |
|
| function |
|
| string |
|
| boolean | Adds the stylesheet to the page once; default |
resolveLanguage(language)
Returns the language Tai Guard writes in for language — the one check(), start() and toHtml() use: resolveLanguage("de-CH") is "de", resolveLanguage("hi") is "en". Use it to tell the shopper that the findings are in English. A value that isn't a string, or is blank, gives "en", although check() and start() refuse a blank one. It writes nothing to the browser console; check(), start() and toHtml() warn the first time a value falls back to English. GUARD_LANGUAGES lists every language Tai Guard writes in.
Finding fields
Every finding has id, code, severity, dimension and title; the rest appear when they apply.
Field | Description |
| The finding's id in this result |
| The kind of risk — a stable key whatever the wording |
| The catalogued risk this finding is, when one fits |
|
|
| Who set the severity, such as |
| An internal grouping; use |
| What the finding is about, e.g. |
| What it rests on, e.g. |
| One line, in the requested language |
| What it means and what to do, in the requested language |
| The booked services it is about, each with |
| The one service it is about, when it is about one |
| When it applies ( |
| By when to act, when there is a deadline |
| Where it applies; |
| The flight segment it is about |
|
|
| What the finding rests on, e.g. a flight's cancellation and delay rates; sent with some findings only, rendered when present |
| Not sent to a checkout today; rendered when present |
The Finding type ships with the package.
CSS custom properties
Set them on the [data-tai-guard] element, for example .checkout [data-tai-guard] { --tai-guard-link: #0050a0; }:
Property | Styles |
| Font family; inherited from the page by default |
| Body and secondary text |
| A finding's background and border |
| Corner radius and spacing |
| Links to affected services |
| The severity badges |
Every element carries a tai-guard__… class, each group data-group-by and data-group (e.g. high, stay, whole_trip; a place's key is opaque), and each finding its keys as data- attributes (data-severity, data-category, data-category-group, data-code, data-segment, data-day, …), data-service-ids (every service it affects) and data-component-ids (the booking-system id of each one that has one), plus the type, dates and place of the service it is placed on: data-service-type, data-start-date, data-end-date, data-city and data-country (left out when it is placed on none), so you can restyle, or show what the card leaves out, beyond the properties. Colours, type, borders and card padding have no specificity, so any selector of yours overrides them. The margins and list layout of lists, cards and headings carry one class, so your page's plain element rules (such as ul, dl, p or h3 from a CSS framework) leave them alone, while a rule that adds a class, such as .content p, still applies. To change them, use a selector with two classes, such as .checkout .tai-guard__list: one class alone ties with the built-in rule and loses. Type stays yours: a framework's h3 size or dt weight applies to the group headings and field labels.
Tips and limits
Send the itinerary string exactly as your server hashed it, without whitespace before or after it. Any change to it — re-serializing it, even reformatting — makes the hash differ and the call fails with
ITINERARY_MISMATCH.Checking the same trip again in the same language (the one it is checked in, so
hiandencount as one) and for the sameaudiencewithin 7 days of the first check finishing returns its result rather than starting a second check, so a page reload doesn't cost a second check. "The same trip" is the token'scache_keywhen you set one, otherwise the trip as Tai Guard reads it.A stored result is up to 7 days old: a flight status or a closure that changed since then without the trip changing is not in it.
A check that failed for a reason on TAI's side (it timed out or never started, for example) or was cancelled is tried again when you repeat it, and that new check is billed. One that failed because of the itinerary, such as
ITINERARY_UNREADABLE, answers with the same error for 7 days rather than costing a second check. A changedcache_key, language oraudiencealways starts a new check; so does a changed trip when the token carries nocache_key. With one, a changed trip under the same key answers from the earlier check, so change the key with the trip.A timeout or an abort ends the wait, not the check: it finishes in the background and the next identical check picks up its result.
The findings are not yet tailored to who reads them. Review what they say before you show them to travellers, and don't present them as written for travellers.
The SDK needs a page served over
https.Clearing the site's data, or a private window, gives the browser a new key. A token bound to the old one then rejects with
DPOP_KEY_MISMATCH; getting the thumbprint and then the token on every page load avoids it.A device clock more than five minutes off makes TAI refuse the signature with
INVALID_DPOP_PROOF,error.details[0].reasoniat.If your page's Content Security Policy blocks inline
<style>elements (the SDK adds one in browsers without constructed stylesheets), or you render inside a shadow root, passinjectStyles: falseand include the exportedTAI_GUARD_CSSstring in your own stylesheet.Each check uses credits from your company's balance, like any agent run.
Related
Agent REST API — the endpoints the SDK calls, tokens and the full error table
Agent authentication — how the OAuth client compares with the other ways to connect
API keys — creating and managing the OAuth client
TAI Chat SDK — embedding the TAI chat in your application
