A first Chat integration needs more than a request and a string: it must preserve the user's permissions, continue a conversation, and consume streamed events correctly. This quickstart shows the modern Platform Chat API with the official TypeScript client's createStream EventStream.
The runnable scaffold uses glean.chat.createStream, the published Glean auth package, and the official API client. Every turn iterates a typed EventStream.
Configure the Platform API client
Resolve the backend from work email or an explicit server URL, then construct Glean with a refreshable OAuth token provider or GLEAN_API_TOKEN.
import { Glean, type SDKOptions } from '@gleanwork/api-client';
import { createGleanTokenProvider, discoverGleanTenant } from '@gleanwork/auth';
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
export interface GleanClientTarget {
email?: string;
serverUrl?: string;
}
async function resolveServerUrl({ email, serverUrl }: GleanClientTarget) {
const explicit = serverUrl?.trim();
if (explicit) return explicit;
const workEmail = email?.trim();
if (workEmail) return (await discoverGleanTenant(workEmail)).serverUrl;
const configured = process.env.GLEAN_SERVER_URL?.trim();
if (configured) return configured;
throw new Error(
'Pass --email or --server-url, or set GLEAN_SERVER_URL in your environment.',
);
}
export async function createGleanClient(target: GleanClientTarget) {
const serverURL = await resolveServerUrl(target);
const server = new URL(serverURL);
const loopback = LOOPBACK_HOSTS.has(server.hostname);
if (
(server.protocol !== 'https:' && !loopback) ||
server.username ||
server.password ||
server.search ||
server.hash ||
(server.pathname && server.pathname !== '/') ||
(!loopback && server.port)
) {
throw new Error('Use a complete Glean backend HTTPS origin.');
}
const staticToken = process.env.GLEAN_API_TOKEN?.trim();
const options = {
serverURL: server.origin,
apiToken:
staticToken ||
createGleanTokenProvider({
serverUrl: server.origin,
scopes: ['chat'],
}),
} satisfies SDKOptions;
return new Glean(options);
}
Iterate createStream events
Call glean.chat.createStream and for-await the typed EventStream. Write each RESPONSE_OUTPUT_TEXT_DELTA.data.delta immediately in raw mode, or accumulate the complete document for terminal rendering. Keep RESPONSE_COMPLETED.data.response for citations.
import type { Glean } from '@gleanwork/api-client';
import type { PlatformChatCompletedResponse } from '@gleanwork/api-client/models/components';
export interface ChatTurn {
completed?: PlatformChatCompletedResponse;
conversationId?: string;
}
export async function streamTurn(
client: Glean,
input: string,
conversationId?: string,
onDelta?: (delta: string) => void,
): Promise<ChatTurn> {
const stream = await client.chat.createStream({
conversation_id: conversationId,
input,
store: true,
});
let completed: PlatformChatCompletedResponse | undefined;
for await (const event of stream) {
switch (event.event) {
case 'RESPONSE_OUTPUT_TEXT_DELTA':
onDelta?.(event.data.delta);
break;
case 'RESPONSE_COMPLETED':
completed = event.data.response;
break;
case 'RESPONSE_FAILED':
throw new Error(event.data.response.error.message);
case 'RESPONSE_CREATED':
case 'RESPONSE_PROGRESS':
case 'RESPONSE_OUTPUT_TEXT_DONE':
break;
default: {
const _exhaustive: never = event;
void _exhaustive;
}
}
}
return {
completed,
conversationId: completed?.conversation_id ?? undefined,
};
}
Continue a conversation and render citations
Store the first turn, pass its conversation_id to a follow-up, and render citation sources and snippets from the completed response.
import type { PlatformChatCompletedResponse } from '@gleanwork/api-client/models/components';
import { createGleanClient, type GleanClientTarget } from './client.js';
import { createMarkdownOutput, type OutputFormat } from './output.js';
import { streamTurn } from './stream.js';
interface OutputTarget {
columns?: number;
isTTY?: boolean;
write(chunk: string): unknown;
}
export interface ChatOptions extends GleanClientTarget {
followUp?: string;
format: OutputFormat;
prompt: string;
}
function printCitations(
response: PlatformChatCompletedResponse,
output: ReturnType<typeof createMarkdownOutput>,
) {
const citations = response.output.flatMap((message) =>
message.content.flatMap((content) => content.annotations ?? []),
);
if (citations.length === 0) return;
let text = '\nSources:\n';
for (const [index, citation] of citations.entries()) {
for (const source of citation.sources) {
const title =
'title' in source && typeof source.title === 'string'
? source.title
: undefined;
const url =
'url' in source && typeof source.url === 'string'
? source.url
: undefined;
text += ` ${index + 1}. ${title ?? url ?? source.type}\n`;
if (url) text += ` ${url}\n`;
}
for (const snippet of citation.snippets ?? []) {
text += ` ${snippet.text}\n`;
}
}
output.plain(text);
}
export async function runChat(
{ email, followUp, format, prompt, serverUrl }: ChatOptions,
target: OutputTarget = process.stdout,
) {
const client = await createGleanClient({ email, serverUrl });
const output = createMarkdownOutput(format, target);
const firstStream = output.stream();
const firstTurn = await streamTurn(client, prompt, undefined, (delta) =>
firstStream.delta(delta),
);
firstStream.complete();
if (firstTurn.completed) printCitations(firstTurn.completed, output);
if (!followUp) return;
if (!firstTurn.conversationId) {
throw new Error(
'The first turn did not return a conversation_id; cannot continue the conversation.',
);
}
output.plain('\nFollow-up:\n');
const followUpStream = output.stream();
const followUpTurn = await streamTurn(
client,
followUp,
firstTurn.conversationId,
(delta) => followUpStream.delta(delta),
);
followUpStream.complete();
if (followUpTurn.completed) printCitations(followUpTurn.completed, output);
}
Report typed SDK failures
Catch errors at the CLI boundary and preserve actionable HTTP status, platform error code, request ID, retry hints, timeout, and connection details without exposing credentials.
import {
ConnectionError,
GleanBaseError,
PlatformProblemDetailError,
RequestTimeoutError,
} from '@gleanwork/api-client/models/errors';
/** Converts typed SDK errors into actionable CLI output. */
export function formatSdkError(error: unknown): string {
if (error instanceof PlatformProblemDetailError) {
const retryAfter = error.headers.get('retry-after');
return [
`HTTP ${error.status}: ${error.detail}`,
`Code: ${error.code}`,
`Request ID: ${error.request_id}`,
retryAfter ? `Retry after: ${retryAfter}` : undefined,
]
.filter(Boolean)
.join('\n');
}
if (error instanceof GleanBaseError) {
return `HTTP ${error.statusCode}: ${error.message}`;
}
if (error instanceof RequestTimeoutError) {
return 'The request timed out. Try again or increase the SDK timeout.';
}
if (error instanceof ConnectionError) {
return `Could not reach Glean: ${error.message}`;
}
return error instanceof Error ? error.message : String(error);
}
npx and npm. Install Node from https://nodejs.org if needed.chat scope through DCRScaffold the project
Copies the runnable TypeScript Chat CLI and fixture tests into a new directory. OAuth login and secure token storage come from the pinned @gleanwork/auth package.
npx -y tiged@2.12.8 gleanwork/glean-cookbook/recipes/streaming-chat-with-citations streaming-chat-with-citations
Install dependencies
cd streaming-chat-with-citations && npm install
Run the fixture tests
Runs Vitest with MSW-backed fixtures, without credentials or live network access, covering typed createStream events, exact-once delta composition, conversation_id propagation, citation separation, and typed SDK error formatting.
npm test
Sign in with OAuth
Discovers your Glean backend from work email and completes Authorization Code with PKCE for Chat and offline_access. Use --server-url for an explicit backend. If DCR is restricted, set GLEAN_OAUTH_CLIENT_ID for an administrator-provisioned public client. If OAuth is not available, set GLEAN_API_TOKEN later as a user-scoped fallback.
npm run login -- --email "<work-email>"
Run one streamed Chat turn
Sends a question through createStream and prints grounded citation data against your own instance. Pipes receive each raw Markdown delta as it arrives. Interactive terminals buffer one complete answer and render it once.
npm run verify -- --email "<work-email>" --prompt "<chat-question>"
Stream a follow-up
Starts a stored conversation, consumes createStream events for each answer, and sends a follow-up using the returned conversation_id. Raw output streams incrementally; terminal-rendered output is buffered per turn.
npm start -- --email "<work-email>" --prompt "<chat-question>" --follow-up "<follow-up-question>"
createStream is SDK-onlyHTTP clients request SSE by setting stream to true in the JSON body. SDK callers use createStream() instead of setting stream on create().
DCR may be disabled or restricted, or may not grant Chat. Use an administrator-provisioned public client when available; a user-scoped CHAT token is the tutorial fallback.
Interactive terminal mode consumes the API EventStream as it arrives but buffers one complete answer before rendering it once. Pipes and redirects receive each raw Markdown delta immediately and get a final newline on completion, so only terminal-rendered output is not token-by-token.
- Render citation spans as links in a web interface using annotation
start_indexandend_index. - Persist
conversation_idin an application session and resume it on the next request. - Add cancellation with
AbortControllerwhen the Chat interaction moves into a UI.
What's our PTO policy?
Returns a non-empty permission-aware streamed response with a conversation_id and at least one citation source when the user's indexed content contains a relevant policy.
Run the authenticate step on this page. It discovers your tenant from work email and signs you in with OAuth, using the shipped login command. If OAuth is unavailable, create a scoped Glean-issued token in Token Management (chat).