Skip to main content

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.

src/client.ts
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.

src/stream.ts
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.

src/chat.ts
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.

src/errors.ts
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);
}
Your CLIprompt and follow-up
TypeScript API clientcreateStream
Platform Chatpermission-aware response
EventStreamtyped createStream events
Node.js 22.12.0 or newer. The steps use npx and npm. Install Node from https://nodejs.org if needed.
A Glean instance with content indexed
Your work email, or the complete Glean backend HTTPS origin
A tenant that permits the public OAuth client and chat scope through DCR
1

Scaffold 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
2

Install dependencies

cd streaming-chat-with-citations && npm install
3

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
4

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>"
5

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>"
6

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>"

HTTP 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.

Take it further
  • Render citation spans as links in a web interface using annotation start_index and end_index.
  • Persist conversation_id in an application session and resume it on the next request.
  • Add cancellation with AbortController when 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.

View source

Runs the recipe through the Glean cookbook plugin.

Auth

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).

At a glance
CapabilitiesChat
SurfacesPlatform API
StatusQuickstart
Time~20 min
Required scopes
CHAT