@laintern/chat-sdk
v0.2.1
Published
Client SDK for talking to a Laintern chat agent: token lifecycle, streaming, citations and sources.
Maintainers
Readme
@laintern/chat-sdk
Client SDK for talking to a Laintern chat agent from your own application: token lifecycle, streaming, citations, sources, guardrails and feedback — without any UI.
npm install @laintern/chat-sdkRequires Node 18+ or a modern browser (fetch, ReadableStream). React is an optional peer dependency, only for the /react entry point.
The one rule
A Laintern API key never goes to the browser. The key mints tokens for any user, so it belongs on your server, behind your own session check. Your backend exchanges it for a short-lived chat token:
POST https://api.laintern.com/v1/auth/token
x-api-key: <your project key>
{ "user_id": "<your stable user id>", "metadata": { ... } }
→ { "data": { "token": "<chat JWT>", "expires_at": "..." } }The SDK calls your endpoint for that token and refreshes it before it expires.
Quick start
import { createLainternClient, createConversation } from "@laintern/chat-sdk"
const client = createLainternClient({
// Your endpoint. Returns { token, expires_at } (or { data: { ... } }).
token: { endpoint: "/api/chat/token" },
storage: sessionStorage, // optional: survive a page reload
})
// `apiBase` defaults to https://api.laintern.com — pass it only for staging
// or a self-hosted deployment.
const conversation = createConversation(client)
conversation.subscribe((state) => render(state.turns))
await conversation.send("Hoe meld ik me aan voor een traject?")send() resolves when the answer is complete; subscribers see every token as it arrives.
React
import { createLainternClient } from "@laintern/chat-sdk"
import { useLainternChat } from "@laintern/chat-sdk/react"
// Create the client once — module scope or a memo in your provider.
const client = createLainternClient({ token: { endpoint: "/api/chat/token" } })
export function Chat() {
const { turns, isStreaming, send, abort, setFeedback } = useLainternChat(client)
return (
<>
{turns.map((turn) => (
<article key={turn.id}>
<p>{turn.userMessage}</p>
{turn.blocked ? (
<p role="alert">{turn.blocked.message}</p>
) : turn.error ? (
<p role="alert">{turn.error.message}</p>
) : (
<p>{turn.assistantContent}</p>
)}
</article>
))}
<button onClick={() => send("Vertel me meer")} disabled={isStreaming}>Verstuur</button>
</>
)
}What a turn holds
| Field | Meaning |
|---|---|
| assistantContent | The answer so far. Markdown, with [1]-style citation markers. |
| citations | Map<number, CitationSource> — the [1] in the text → the source it points at. A source with an empty url (e.g. a knowledge-base passage) is not navigable: render title + section, not a link. |
| sources | Everything the agent cited or consulted, server-ranked. |
| externalSources | Suggestions per content type, for a sidebar. Not necessarily cited. Keep the server's order — it is ranked, not alphabetical. |
| steps | Tool activity, for a progress indicator. A completed step replaces its own "started" entry. |
| advisory | A soft notice next to a normal answer. |
| blocked | A guardrail refused the turn; there is no answer. Render blocked.message in place of the answer. |
| error | The turn failed. See the error codes below. |
| messageId | Set once the turn completes; required for setFeedback. |
Errors
send() never throws — a failed turn is part of the transcript and lands on turn.error. Direct client calls (getConfig, submitFeedback) throw a LainternError with:
| code | Meaning |
|---|---|
| RATE_LIMIT_EXCEEDED | Too many messages. rateLimitScope / rateLimitWindow say which limit. |
| GUARDRAIL_TRIGGERED | Surfaces as turn.blocked, not turn.error. |
| MCP_UNAVAILABLE | A dependency of the agent failed. reason (on the error and on turn.error) says which: "unauthenticated" means the user's session expired — reload and re-mint; anything else is an outage. |
| TOKEN_FETCH_FAILED / TOKEN_INVALID | Your token endpoint failed or returned no token. |
| NETWORK_ERROR | Transport failure. |
error.isRetryable is true for a rate limit or a transport failure; an expired session is not — mint a new token first.
Token lifetime
The SDK reads the JWT's exp and refreshes 60 seconds before it, via your endpoint. That margin matters: when the API mints a token around a credential of your own (an upstream session token passed in metadata), it clamps the chat token's expiry to that credential. The refresh therefore lands back at your endpoint while the credential is still alive — so re-check the user's session there and mint a fresh upstream credential rather than returning the old one.
Pass storage: sessionStorage (or your own { getItem, setItem, removeItem }) to survive reloads. Storage that throws — private mode, blocked cookies — is handled: the SDK just refetches.
Citations
The answer text carries [1]-style markers. Resolve them without writing a parser:
import { segmentAnswer, isNavigable } from "@laintern/chat-sdk"
for (const segment of segmentAnswer(turn.assistantContent, turn)) {
if (segment.type === "text") render(segment.text)
else if (segment.source && isNavigable(segment.source)) renderLink(segment.source, segment.index)
else renderPlainMarker(segment.index) // knowledge-base passage, or not resolved yet
}Two things to expect. A source with an empty url is normal — a knowledge-base passage lives in the client's document store, not on the web; isNavigable() tells you, and section is the heading path inside the document. And a marker can render before its citation event arrives, because the answer streams token by token: segmentAnswer falls back to source order, and yields source: null when even that is not available yet.
Conversation history
The server stores every conversation, keyed to the user_id your backend put in the token. That gives you a history sidebar — list, reopen, "new conversation", delete — without storing anything yourself:
// The user's past conversations, newest first. Cursor-paginated.
const { sessions, nextCursor } = await client.listSessions()
sessions[0].firstUserMessage // ready-made sidebar title
// Reopen one: fetch the transcript, rebuild it as turns, load it.
import { turnsFromSession } from "@laintern/chat-sdk"
const detail = await client.getSession(sessions[0].id)
conversation.load({ sessionId: detail.id, turns: turnsFromSession(detail) })
await conversation.send("…") // continues that session server-side
conversation.reset() // "new conversation"
await client.deleteSession(sessions[0].id)In React, useLainternSessions(client, { refreshKey }) (from @laintern/chat-sdk/react) keeps the list in state with refresh and remove; pass the live sessionId as refreshKey so a first answer's new session shows up.
One requirement: the history is only as stable as the user_id your token endpoint sends. Use a logged-in user id — or a server-set cookie UUID for anonymous visitors. A random id per page load means an empty sidebar on every visit.
Restored turns are faithful with one caveat: the exact [n] → source binding only exists while an answer streams, so turnsFromSession resolves markers positionally (the n-th stored source is [n]), the same fallback resolveCitation uses live. A turn a guardrail blocked restores as turn.blocked with the message the user actually saw.
API
createLainternClient(options)→sendMessage()(async iterable of raw events),getConfig(),submitFeedback(),listSessions(),getSession(),deleteSession(),tokenscreateConversation(client, options?)→getState(),subscribe(),send(),abort(),reset(),load(),setFeedback()useLainternChat(client, options?),useLainternSessions(client, options?)— React bindings,@laintern/chat-sdk/reactturnsFromSession(detail)— rebuild a stored transcript as turns forload()/initialTurnssegmentAnswer(text, turn),resolveCitation(index, turn),isNavigable(source)— citation helpersparseSSE(response),createTokenManager(options)— the building blocks, if you want to assemble your own
getConfig() returns the agent's transparency disclaimer. If it is enabled, show it at the start of every conversation — that is an EU AI Act obligation on the deployer, not a decoration.
Per-request metadata
send(message, { metadata }) forwards an object to the agent's run state,
readable in the prompt as {{state.*}}. Use it for context the agent should
know but the user did not type — the Laintern dashboard sends
{ dashboard: { view, project_id } } so Bulb knows which screen you are on.
