Passa al contenuto principale

Chat SDK Headless

Il Tidiko Chat SDK è il client headless (senza UI) per integrare la chat Tidiko in un sito o in un'app. Stesso motore del widget embeddable: autenticazione JWT, socket, thread, privacy, storico, streaming e form di contatto.

Versione corrente del contratto pubblico: 0.8.0 (CHAT_SDK_VERSION). Eventi, stato, status machine e modello errori sono semver-stable.

Widget, SDK o REST?

Usa il widget Preact se vuoi la UI Tidiko già pronta (Shadow DOM, temi, markdown, product card).

Usa questo SDK se vuoi disegnare tu la chat: React, Vue, vanilla, mobile web. L'SDK gestisce connessione e regole; tu renderizzi getState() e ascolti gli eventi.

Per backend / CLI / HTTP puro (JSON o SSE, senza Socket.io) usa la Chat REST API. Per provarla in dashboard: Playground. Spec interattiva: Chat API docs.

Cosa fa e cosa non fa

FaNon fa
Auth JWT, refresh, lock anti-raceUI, CSS, markdown, icone
Socket.io verso il Node AgentChiamate REST/SSE (quelle sono un altro percorso)
Privacy gate + cookie consensoPersistenza consenso lato server obbligatoria (opzionale via endpoint)
Thread, storico, welcome, streamingParsing product card (resta nel contenuto messaggio)
Form contatto HITLValidazione visiva dei campi (tu disegni il form)

Artifact di build

Generati da npm run build:sdk nel repo Laravel (neting-ai) e serviti da public/build/:

FileFormatoUso
tidiko-chat-sdk.jsIIFE<script>window.TidikoChat
tidiko-chat-sdk.esm.jsESMimport { createTidikoChat }
tidiko-chat-sdk.d.tsTypes entryTypeScript
tidiko-chat-sdk-types/Albero .d.tsPath mapping completo

Budget di size (CI fallisce se superato): 900 KB per IIFE e ESM. Il widget Preact è un bundle separato.

Demo vanilla nel repo Laravel: examples/headless-sdk.html.

Flusso end-to-end

  1. Ottieni un JWT di chat (chiave pk_ nel browser, mai sk_).
  2. Crei un'istanza con createTidikoChat.
  3. Registri listener prima di start().
  4. start() autentica e connette. Si risolve quando status === "ready".
  5. Mostri il banner privacy e chiami acceptPrivacy() solo da un controllo esplicito.
  6. Invi con sendMessage. Lo stato in getState().chat.messages è la source of truth.
  7. A unmount / cambio pagina: unsubscribe e destroy().

Caricamento

Script tag (IIFE)

<script src="https://YOUR_APP_URL/build/tidiko-chat-sdk.js"></script>
<script>
const chat = window.TidikoChat.createTidikoChat({
socketUrl: "https://SOCKET_URL",
appUrl: "https://YOUR_APP_URL",
assistantId: "AGENT_UUID",
jwt: "…",
options: { defaultMessage: "Ciao! Come posso aiutarti?" },
});
</script>

Superficie globale: TidikoChat.createTidikoChat, TidikoChat.validateTidikoChatConfig, TidikoChat.CHAT_SDK_VERSION.

ESM

import {
createTidikoChat,
validateTidikoChatConfig,
CHAT_SDK_VERSION,
} from "https://YOUR_APP_URL/build/tidiko-chat-sdk.esm.js";

const chat = createTidikoChat({
socketUrl: "https://SOCKET_URL",
appUrl: "https://YOUR_APP_URL",
assistantId: "AGENT_UUID",
jwt: "…",
});

TypeScript

Punta compilerOptions.paths (o types) a:

  • public/build/tidiko-chat-sdk.d.ts (entry), oppure
  • public/build/tidiko-chat-sdk-types/ (albero completo)

Tipi pubblici stabili: TidikoChatState, TidikoError, TidikoChatEventMap, TidikoStatus, TidikoChatConfig.

validateTidikoChatConfig(config) lancia subito se la config è invalida. createTidikoChat la chiama internamente.

Configurazione

Campi obbligatori a runtime

validateTidikoChatConfig richiede stringhe non vuote per:

CampoRuolo
socketUrlURL del Node Agent (Socket.io)
assistantIdUUID agente o assistente
jwtAccess JWT di chat (aud: tidiko-chat)

Il tipo TypeScript richiede anche appUrl (base Laravel). Passalo sempre: serve al refresh e al payload laravelAppUrl sul socket.

Campi opzionali

CampoTipoRuolo
jwtRefreshstringRefresh token. Senza di esso, a JWT scaduto start() fallisce con auth_failed
languagestringLingua inviata al Node Agent
type"assistant" | "agent"Default "assistant". Se assistantId non contiene _, viene prefissato (assistant_… / agent_…)
collectionNamestringCollection RAG
companyName / companyDescriptionstringContesto azienda sul payload socket
baseFeedUrlstringFeed prodotti
hasDocumentsbooleanFlag knowledge base
websiteUrlstringSito host (anche sourceUrl del form contatto)
assistantInstructionsstringIstruzioni extra
payloadobjectMetadata custom inoltrati sul socket
errorMessagesTidikoErrorMessagesOverride testi errore per code
messagesobjectOverride copy di dominio (welcome_fallback, contact_form_timeout, …)

options

options?: {
defaultMessage?: string; // welcome se privacy ok e nessun thread
privacyEndpoint?: string; // POST { accepted: true } dopo acceptPrivacy
csrfToken?: string; // header X-CSRF-TOKEN verso privacyEndpoint
contactForm?: {
enabled?: boolean;
fields?: Array<{ key: string; type?: string; required?: boolean }>;
submitUrl?: string;
};
}

options.faq esiste nel tipo ma non è usato dal client headless: le FAQ arrivano dai blocchi <questions> nei messaggi assistente. Disegna tu i chip da message.questions.

Porte iniettabili (test / Node)

Puoi sostituire storage, cookie, fetch, factory socket, clock, ids, logger, htmlDecode, translate. In browser il factory inietta default (localStorage, cookie documento, fetch, Socket.io). In Node (senza document) usa storage/cookie in memoria.

API pubblica

createTidikoChat(config) restituisce un'istanza isolata (TidikoChat). Una pagina può crearne più di una (un agente per istanza).

MetodoCosa fa
getState()Snapshot corrente. Source of truth per la UI
subscribe(fn)fn(next, prev) a ogni dispatch. Ritorna unsubscribe
on(event, handler)Eventi pubblici. Ritorna unsubscribe
observe(handler)Stream low-level: (action, prev, next). Per debug, non per UI
start()Auth + connect. Resolve su ready. Reject con Error & TidikoError
sendMessage(text)Gate privacy/ready/running + ensure thread + emit. Vedi SendResult
acceptPrivacy()Consenso esplicito. Sblocca welcome e invio
clearChat()Cancella messaggi, thread persistito, form contatto; ricalcola welcome
retryHistory()Riprova il load storico sul thread corrente
serializeTranscript()Testo piano You: / Assistant: (HTML stripped, no loading/tool)
contactForm.submit(values)Invia il form HITL sul socket
contactForm.cancel()Chiude e notifica il server
contactForm.heartbeat()Keep-alive (minimo 12s tra un emit e l'altro)
destroy()Disconnect socket, status idle. L'istanza non è più usabile

on() non ha un off(event, handler) separato: tieni la funzione ritornata e chiamala.

const offReady = chat.on("ready", () => {});
// cleanup:
offReady();
chat.destroy();

start()

  • Idempotente se status === "ready".
  • Dopo destroy() lancia config_invalid con field: "destroyed".
  • Sequenza interna: authenticating → JWT fresco → flag privacy dal cookie → connect socket → ready.
  • Se esiste un thread in storage per la sessione JWT (sid), carica lo storico. I messaggi restano in buffer finché la privacy non è accettata.
  • Reject su errori prima di ready (auth / connect). Dopo ready, gli errori arrivano solo come evento error.

sendMessage(text)SendResult

Non lancia. Restituisce un risultato discriminato:

type SendResult =
| { ok: true; threadId: string }
| {
ok: false;
reason:
| "not_ready" // destroy, status ≠ ready, o socket down
| "privacy" // consenso mancante
| "send_in_progress" // isRunning
| "empty" // testo vuoto dopo trim
| "thread_failed"; // create_thread timeout (30s) o disconnect
};

Se ok: false e reason === "thread_failed", lo store ha già fatto rollback del messaggio utente e ha emesso error (thread_create_timeout o connection_lost).

const result = await chat.sendMessage(text);
if (!result.ok) {
if (result.reason === "privacy") showPrivacyBanner();
if (result.reason === "not_ready") showOffline();
}

acceptPrivacy()

Chiamalo solo da un click/tap dell'utente (consenso GDPR).

  1. Imposta il cookie tidiko_widget_privacy_accepted=1 (path=/, 1 anno).
  2. Emette privacy_accepted.
  3. Se lo storico era in buffer, lo applica (niente welcome).
  4. Altrimenti connette/bootstrap e, se non ci sono messaggi, mostra options.defaultMessage.
  5. Se options.privacyEndpoint è valorizzato, fa POST JSON { accepted: true } (failure solo in log, il consenso locale resta).

clearChat()

Cancella il thread in localStorage per la sessione corrente, chiude il form contatto, svuota i messaggi e ricalcola il welcome. Non disconnette il socket.

destroy()

Disconnette e azzera i servizi. In React/Preact chiamalo nel cleanup dell'useEffect. Dopo destroy(), sendMessage torna { ok: false, reason: "not_ready" }.

Macchina di stato

idle → authenticating → connecting → ready
↘ reconnecting
↘ error
StatusQuando
idleAppena creato, o dopo destroy()
authenticatingstart() sta rinnovando/validando il JWT
connectingSocket in connessione
readyPuoi inviare (se privacy ok)
reconnectingDrop di rete, tentativo automatico
errorAuth o connect falliti prima di ready

Ogni cambio di status emette connection_changed. Il passaggio a ready emette anche ready.

Stato (TidikoChatState)

{
status: TidikoStatus;
auth: { hasToken: boolean; sessionId: string | null; ready: boolean };
connection: { connected: boolean; badge: string | null; toast: string | null };
privacy: { accepted: boolean; required: boolean };
thread: {
id: string | null;
historyStatus: "idle" | "loading" | "loaded" | "error";
pendingLoadedMessages: TidikoChatMessage[] | null;
};
chat: {
messages: TidikoChatMessage[];
isRunning: boolean;
showFaq: boolean;
streaming: { buffer: string; messageId: string | null; kind: "live" | "deferred" | null };
lastOutboundPayload: Record<string, unknown> | null;
};
contactForm: { open: boolean; requestId: string | null };
lastError: TidikoError | null;
}
  • auth espone solo metadati. Il JWT raw resta dentro AuthService.
  • connection.badge / toast sono stringhe UX pronte (es. reconnect). Puoi ignorarle e usare solo status.
  • thread.pendingLoadedMessages è lo storico scaricato prima del consenso. Non renderizzarlo: aspetta history_loaded / privacy_accepted.
  • chat.isRunning è il gate anti doppio-invio.
  • chat.streaming.buffer è il testo in arrivo; alla fine diventa un messaggio kind: "text".

Messaggio (TidikoChatMessage)

{
id: string;
role: "user" | "assistant" | "system";
kind: "text" | "error" | "welcome" | "tool" | "faq" | "breathing" | "loading";
content: string;
createdAt: number;
questions?: string[] | null;
showFAQ?: boolean;
showSkeletonFAQ?: boolean;
isStreaming?: boolean;
isBreathing?: boolean;
isToolFeedbackMessage?: boolean;
streamingId?: string;
}

Regole di render consigliate:

  • Salta kind: "loading" (placeholder interno).
  • kind: "welcome" = messaggio di default, non è storico.
  • kind: "tool" / isBreathing = feedback tool in corso (shimmer).
  • Se showFAQ && questions?.length, mostra chip. Clic = sendMessage(question).
  • showSkeletonFAQ = blocco <questions> ancora incompleto nello stream.
  • content dell'assistente può contenere markdown e markup <product-card>. L'SDK non estrae le card: lo fa il widget. In headless parsi tu il contenuto.

Eventi

EventoPayloadQuando
ready{ status }status diventa ready
connection_changed{ status, previous }Ogni transizione di status
stream_started{ messageId }Inizio risposta
stream_chunk{ messageId, text }Chunk live (non deferred)
stream_ended{ messageId, text }Fine risposta (testo completo)
message{ message }Messaggio utente aggiunto o append assistente
history_loaded{ messageCount }Storico applicato (dopo privacy o se già accettata)
privacy_required{}privacy_set con accepted: false
privacy_accepted{}Consenso salvato
contact_form_opened{ requestId }Il server chiede un form HITL
contact_form_closed{ reason? }Form chiuso
welcome_shown{ messageId }Welcome materializzato
errorTidikoErrorErrore tipizzato

Per la UI preferisci subscribe + getState() (un solo re-render). Usa on("error") e on("privacy_required") per side-effect (toast, banner).

Errori

TidikoError.code è stabile. message non lo è: default inglese, sovrascrivibile. Nei test e nel codice asserisci sul code.

chat.on("error", (err) => {
console.error(err.code, err.message);
});

await chat.start().catch((err) => {
// Error & TidikoError
if (err.code === "auth_failed") askNewToken();
});
codeCampi extraQuando
config_invalidfieldConfig mancante/vuota, o start() dopo destroy
auth_failedcause?JWT assente o refresh fallito
connection_lostDisconnect durante create_thread
thread_create_timeoutcreate_thread > 30s
send_failedtextInvio fallito (il testo utente è in text)
history_timeoutLoad storico > timeout interno
contact_submit_failedSubmit form / timeout form
socket_errordetail?Errore socket generico

Override copy:

createTidikoChat({
// …
errorMessages: {
connection_lost: "Riconnessione in corso…",
auth_failed: (err) => `Accesso negato: ${err.message}`,
},
});

SendResult.reason non è un TidikoError.code. Sono due contratti diversi: i gate di invio vs gli errori di dominio.

Privacy e welcome

Precedenza welcome (una sola regola, lato SDK):

  1. Se privacy ok e c'è storico in buffer → applica lo storico, niente welcome.
  2. Se c'è options.defaultMessage e privacy ok e nessun thread (né in store né in storage) e lo storico non sta caricando → mostra welcome.
  3. Altrimenti niente.

Il cookie di consenso è condiviso col widget: tidiko_widget_privacy_accepted. Un utente che ha già accettato sul widget embed non rivede il banner.

Thread e persistenza

Le chiavi dipendono dal sid del JWT (claim sessione), non solo dall'agente:

ChiaveUso
tidiko_thread_{assistantId}_{sid}Thread corrente (scoped)
tidiko_thread_{assistantId}Legacy, letto una volta poi migrato
tidiko_thread_rejected_{assistantId}_{sid}Legacy scartato

clearChat() rimuove lo scoped (e il legacy). Un nuovo sendMessage crea un thread nuovo.

Il JWT access è in tidiko_jwt_{instanceId}; il refresh in tidiko_jwt_refresh_{instanceId}.

Token: come ottenere jwt

Chiave pubblica (pk_) — browser

Crea la chiave da API Keys in dashboard. allowed_origins governa solo CORS, non è un confine di auth. L'autorizzazione è pk_ + binding company/agente.

curl -X POST https://YOUR_APP_URL/api/v1/token \
-H "Content-Type: application/json" \
-H "Origin: https://tuo-sito.example" \
-d '{"public_key":"pk_live_xxx","agent_id":"AGENT_UUID","type":"agent"}'

Risposta (data):

CampoNote
token / jwtAccess JWT, TTL 3600s
refresh_token / jwtRefreshRefresh single-use (JTI). TTL 86400s
expires_in3600
socket_urlURL Node Agent da mettere in socketUrl

Poi:

const chat = createTidikoChat({
socketUrl: data.socket_url,
appUrl: "https://YOUR_APP_URL",
assistantId: "AGENT_UUID",
type: "agent",
jwt: data.token,
jwtRefresh: data.refresh_token,
});

Refresh

  • Manuale (integratore pk_): POST /api/v1/token/refresh con { "refresh_token": "…" }. Non riusare un refresh già speso.
  • Automatico (dentro l'SDK): a start(), se il JWT in storage sta per scadere (buffer 60s), il client chiama POST {appUrl}/api/jwt/refresh con lo stesso body. È il path del widget embed.

Se gestisci tu i token pk_, rinnova con /api/v1/token/refresh e ricrea (o ri-passa) JWT freschi alla factory.

Chiave segreta (sk_) — solo server

curl -X POST https://YOUR_APP_URL/api/v1/service-token \
-H "Content-Type: application/json" \
-d '{"secret_key":"sk_live_xxx","agent_id":"AGENT_UUID","type":"agent"}'

Mai in JavaScript di pagina, mai in repo frontend, mai in un playground pubblico. Per chat senza UI dal backend usa REST/SSE sul Node Agent con il JWT ottenuto da service-token, non questo SDK.

Esempio vanilla

<script src="https://YOUR_APP_URL/build/tidiko-chat-sdk.js"></script>
<button id="accept-privacy">Accetto la privacy</button>
<input id="text" placeholder="Messaggio" />
<button id="send">Invia</button>
<ul id="log"></ul>
<script>
(async () => {
const log = document.getElementById("log");
const add = (label, text) => {
const li = document.createElement("li");
li.textContent = label + ": " + text;
log.appendChild(li);
};

const chat = window.TidikoChat.createTidikoChat({
socketUrl: "https://SOCKET_URL",
appUrl: "https://YOUR_APP_URL",
assistantId: "AGENT_UUID",
jwt: "…",
jwtRefresh: "…",
type: "agent",
options: { defaultMessage: "Ciao! Come posso aiutarti?" },
});

const render = () => {
log.replaceChildren();
for (const m of chat.getState().chat.messages) {
if (m.kind === "loading") continue;
add(m.role, m.content);
}
};

chat.subscribe(render);
chat.on("privacy_required", () => add("system", "Mostra il banner privacy"));
chat.on("error", (err) => add("error", err.code));

await chat.start();

document.getElementById("accept-privacy").onclick = () => {
void chat.acceptPrivacy();
};
document.getElementById("send").onclick = async () => {
const input = document.getElementById("text");
const result = await chat.sendMessage(input.value);
if (result.ok) {
input.value = "";
} else {
add("send", result.reason);
}
};
})().catch((err) => console.error(err.code || err, err.message || err));
</script>

React / Preact (useSyncExternalStore)

import { useEffect, useMemo, useSyncExternalStore } from "react";
import { createTidikoChat } from "/build/tidiko-chat-sdk.esm.js";

function useTidikoChatState(chat) {
return useSyncExternalStore(
(onStoreChange) => chat.subscribe(() => onStoreChange()),
() => chat.getState(),
);
}

export function ChatPanel({ config }) {
// Caller must pass a stable `config` object (useMemo / useRef upstream).
const chat = useMemo(() => createTidikoChat(config), [config]);

useEffect(() => {
void chat.start().catch((err) => {
console.error(err.code || err, err.message || err);
});
return () => chat.destroy();
}, [chat]);

const state = useTidikoChatState(chat);
if (!state) return null;

return (
<div>
{!state.privacy.accepted && (
<button type="button" onClick={() => void chat.acceptPrivacy()}>
Accetto la privacy
</button>
)}
<ul>
{state.chat.messages
.filter((m) => m.kind !== "loading")
.map((m) => (
<li key={m.id}>
<strong>{m.role}</strong> {m.content}
{m.showFAQ &&
m.questions?.map((q) => (
<button key={q} type="button" onClick={() => void chat.sendMessage(q)}>
{q}
</button>
))}
</li>
))}
</ul>
<button
type="button"
disabled={state.chat.isRunning || !state.privacy.accepted}
onClick={() => void chat.sendMessage("Ciao")}
>
Invia
</button>
</div>
);
}

Adapter interno (stesso pattern, dipendenza preact/compat): resources/js/chat-sdk/adapters/react.ts nel repo Laravel.

Stabilizza config (memo / deps esplicite). Ricreare il client a ogni render fa start() + destroy() in loop.

Form di contatto (HITL)

Quando il Node Agent emette contact_form_request, lo store apre il form:

let heartbeatTimer = null;

const stopHeartbeat = () => {
if (heartbeatTimer !== null) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};

chat.on("contact_form_opened", ({ requestId }) => {
showForm(requestId);
stopHeartbeat();
heartbeatTimer = setInterval(() => chat.contactForm.heartbeat(), 12_000);
});

chat.on("contact_form_closed", () => {
hideForm();
stopHeartbeat();
});

// submit: includi i field key configurati + privacy: true
await chat.contactForm.submit({
name: "Anna",
email: "anna@example.com",
privacy: true,
website: "", // honeypot: se compilato il server scarta
});
stopHeartbeat();

// oppure
chat.contactForm.cancel();
stopHeartbeat();

// unmount / destroy
stopHeartbeat();
chat.destroy();

values.privacy è obbligatorio nella validazione interna. Il campo website è honeypot: lascialo vuoto nel form reale, nascondilo visivamente.

Checklist di integrazione

  1. Chiave pk_ in dashboard, origini CORS impostate.
  2. POST /api/v1/token dal tuo frontend → jwt, jwtRefresh, socket_url.
  3. Carica IIFE o ESM da YOUR_APP_URL/build/….
  4. createTidikoChat con socketUrl, appUrl, assistantId, jwt.
  5. on / subscribe prima di start().
  6. Banner privacy → acceptPrivacy() solo su gesto utente.
  7. Input disabilitato se !privacy.accepted o chat.isRunning o status !== "ready".
  8. Cleanup: unsubscribe + destroy().
  9. Mai sk_ nel browser.

Sicurezza

  • Non esporre sk_ in HTML, bundle frontend, playground pubblici.
  • Non riusare un refresh token già speso (JTI single-use).
  • Il JWT access non va loggato per intero.
  • allowed_origins sulla pk_ è CORS, non auth: un Origin spoofato non basta a rubare la chiave, ma una pk_ leakata sì. Revoca da /apikeys.
  • acceptPrivacy() non è un dettaglio UX: è il gate legale. Non chiamarlo in automatico al load.

Build e test (maintainer Laravel)

npm run build:sdk
npm run typecheck:sdk
npm run test:unit

Soglie size in scripts/build-chat-sdk.js. Changelog del contratto: resources/js/chat-sdk/CHANGELOG.md.