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.
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
| Fa | Non fa |
|---|---|
| Auth JWT, refresh, lock anti-race | UI, CSS, markdown, icone |
| Socket.io verso il Node Agent | Chiamate REST/SSE (quelle sono un altro percorso) |
| Privacy gate + cookie consenso | Persistenza consenso lato server obbligatoria (opzionale via endpoint) |
| Thread, storico, welcome, streaming | Parsing product card (resta nel contenuto messaggio) |
| Form contatto HITL | Validazione 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/:
| File | Formato | Uso |
|---|---|---|
tidiko-chat-sdk.js | IIFE | <script> → window.TidikoChat |
tidiko-chat-sdk.esm.js | ESM | import { createTidikoChat } |
tidiko-chat-sdk.d.ts | Types entry | TypeScript |
tidiko-chat-sdk-types/ | Albero .d.ts | Path 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
- Ottieni un JWT di chat (chiave
pk_nel browser, maisk_). - Crei un'istanza con
createTidikoChat. - Registri listener prima di
start(). start()autentica e connette. Si risolve quandostatus === "ready".- Mostri il banner privacy e chiami
acceptPrivacy()solo da un controllo esplicito. - Invi con
sendMessage. Lo stato ingetState().chat.messagesè la source of truth. - 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), oppurepublic/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:
| Campo | Ruolo |
|---|---|
socketUrl | URL del Node Agent (Socket.io) |
assistantId | UUID agente o assistente |
jwt | Access 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
| Campo | Tipo | Ruolo |
|---|---|---|
jwtRefresh | string | Refresh token. Senza di esso, a JWT scaduto start() fallisce con auth_failed |
language | string | Lingua inviata al Node Agent |
type | "assistant" | "agent" | Default "assistant". Se assistantId non contiene _, viene prefissato (assistant_… / agent_…) |
collectionName | string | Collection RAG |
companyName / companyDescription | string | Contesto azienda sul payload socket |
baseFeedUrl | string | Feed prodotti |
hasDocuments | boolean | Flag knowledge base |
websiteUrl | string | Sito host (anche sourceUrl del form contatto) |
assistantInstructions | string | Istruzioni extra |
payload | object | Metadata custom inoltrati sul socket |
errorMessages | TidikoErrorMessages | Override testi errore per code |
messages | object | Override 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).
| Metodo | Cosa 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()lanciaconfig_invalidconfield: "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). Dopoready, gli errori arrivano solo come eventoerror.
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).
- Imposta il cookie
tidiko_widget_privacy_accepted=1(path=/, 1 anno). - Emette
privacy_accepted. - Se lo storico era in buffer, lo applica (niente welcome).
- Altrimenti connette/bootstrap e, se non ci sono messaggi, mostra
options.defaultMessage. - Se
options.privacyEndpointè valorizzato, faPOSTJSON{ 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
| Status | Quando |
|---|---|
idle | Appena creato, o dopo destroy() |
authenticating | start() sta rinnovando/validando il JWT |
connecting | Socket in connessione |
ready | Puoi inviare (se privacy ok) |
reconnecting | Drop di rete, tentativo automatico |
error | Auth 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;
}
authespone solo metadati. Il JWT raw resta dentroAuthService.connection.badge/toastsono stringhe UX pronte (es. reconnect). Puoi ignorarle e usare solostatus.thread.pendingLoadedMessagesè lo storico scaricato prima del consenso. Non renderizzarlo: aspettahistory_loaded/privacy_accepted.chat.isRunningè il gate anti doppio-invio.chat.streaming.bufferè il testo in arrivo; alla fine diventa un messaggiokind: "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.contentdell'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
| Evento | Payload | Quando |
|---|---|---|
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 |
error | TidikoError | Errore 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();
});
code | Campi extra | Quando |
|---|---|---|
config_invalid | field | Config mancante/vuota, o start() dopo destroy |
auth_failed | cause? | JWT assente o refresh fallito |
connection_lost | — | Disconnect durante create_thread |
thread_create_timeout | — | create_thread > 30s |
send_failed | text | Invio fallito (il testo utente è in text) |
history_timeout | — | Load storico > timeout interno |
contact_submit_failed | — | Submit form / timeout form |
socket_error | detail? | 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):
- Se privacy ok e c'è storico in buffer → applica lo storico, niente welcome.
- Se c'è
options.defaultMessagee privacy ok e nessun thread (né in store né in storage) e lo storico non sta caricando → mostra welcome. - 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:
| Chiave | Uso |
|---|---|
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):
| Campo | Note |
|---|---|
token / jwt | Access JWT, TTL 3600s |
refresh_token / jwtRefresh | Refresh single-use (JTI). TTL 86400s |
expires_in | 3600 |
socket_url | URL 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/refreshcon{ "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 chiamaPOST {appUrl}/api/jwt/refreshcon 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
- Chiave
pk_in dashboard, origini CORS impostate. POST /api/v1/tokendal tuo frontend →jwt,jwtRefresh,socket_url.- Carica IIFE o ESM da
YOUR_APP_URL/build/…. createTidikoChatconsocketUrl,appUrl,assistantId,jwt.on/subscribeprima distart().- Banner privacy →
acceptPrivacy()solo su gesto utente. - Input disabilitato se
!privacy.acceptedochat.isRunningostatus !== "ready". - Cleanup: unsubscribe +
destroy(). - 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_originssullapk_è CORS, non auth: un Origin spoofato non basta a rubare la chiave, ma unapk_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.