Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ A powerful, type-safe AI SDK for building AI-powered applications.
- Headless chat state management with adapters (SSE, HTTP stream, custom)
- Isomorphic type-safe tools with server/client execution
- **Enhanced integration with TanStack Start** - Share implementations between AI tools and server functions
- **Observability events** - Structured, typed events for text, tools, image, speech, transcription, and video ([docs](./docs/guides/observability.md))

### <a href="https://tanstack.com/ai">Read the docs →</b></a>

Expand Down
33 changes: 31 additions & 2 deletions docs/guides/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ the `{ withEventTarget: true }` option.
This will not only emit to the event bus (which is not present in production), but to the current eventTarget that
you will be able to listen to.

## Event naming scheme

Events follow the format `<system-part>:<what-it-does>`.

- Text: `text:request:started`, `text:message:created`, `text:chunk:content`, `text:usage`
- Tools: `tools:approval:requested`, `tools:call:completed`, `tools:result:added`
- Summarize: `summarize:request:started`, `summarize:usage`
- Image: `image:request:started`, `image:usage`
- Speech: `speech:request:started`, `speech:usage`
- Transcription: `transcription:request:started`, `transcription:usage`
- Video: `video:request:started`, `video:usage`
- Client: `client:created`, `client:loading:changed`, `client:messages:cleared`

Every event includes all metadata available at the time of emission (model, provider,
system prompts, request and message IDs, options, and tool names).

## Server events

There are both events that happen on the server and on the client, if you want to listen to either side you just need to
Expand All @@ -28,7 +44,7 @@ Here is an example for the server:
import { aiEventClient } from "@tanstack/ai/event-client";

// server.ts file or wherever the root of your server is
aiEventClient.on("chat:started", e => {
aiEventClient.on("text:request:started", e => {
// implement whatever you need to here
})
// rest of your server logic
Expand All @@ -46,7 +62,7 @@ import { aiEventClient } from "@tanstack/ai/event-client";

const App = () => {
useEffect(() => {
const cleanup = aiEventClient.on("client:tool-call-updated", e => {
const cleanup = aiEventClient.on("tools:call:updated", e => {
// do whatever you need to do
})
return cleanup;
Expand All @@ -55,4 +71,17 @@ const App = () => {
}
```

## Reconstructing chat

To rebuild a chat timeline from events, listen for:

- `text:message:created` (full message content)
- `text:message:user` (explicit user message events)
- `text:chunk:*` (streaming content, tool calls, tool results, thinking)
- `tools:*` (approvals, input availability, call completion)
- `text:request:completed` (final completion + usage)

This set is sufficient to replay the conversation end-to-end for observability and
telemetry systems.


5 changes: 4 additions & 1 deletion examples/ts-react-chat/src/routes/api.tanchat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,13 @@ export const Route = createFileRoute('/api/tanchat')({
}),
openrouter: () =>
createChatOptions({
adapter: openRouterText('openrouter/auto'),
adapter: openRouterText('openai/gpt-5.1'),
modelOptions: {
models: ['openai/chatgpt-4o-latest'],
route: 'fallback',
reasoning: {
effort: 'medium',
},
},
}),
gemini: () =>
Expand Down
22 changes: 14 additions & 8 deletions packages/typescript/ai-client/src/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,16 @@ export class ChatClient {
input: any
approvalId: string
}) => {
this.events.approvalRequested(
this.currentMessageId || '',
args.toolCallId,
args.toolName,
args.input,
args.approvalId,
)
if (this.currentStreamId) {
this.events.approvalRequested(
this.currentStreamId,
this.currentMessageId || '',
args.toolCallId,
args.toolName,
args.input,
args.approvalId,
)
}
},
},
})
Expand Down Expand Up @@ -210,7 +213,10 @@ export class ChatClient {
parts: [],
createdAt: new Date(),
}
this.events.messageAppended(assistantMessage)
this.events.messageAppended(
assistantMessage,
this.currentStreamId || undefined,
)

// Process each chunk
for await (const chunk of source) {
Expand Down
86 changes: 37 additions & 49 deletions packages/typescript/ai-client/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export abstract class ChatClientEventEmitter {
*/
protected abstract emitEvent(
eventName: string,
data?: Record<string, any>,
data?: Record<string, unknown>,
): void

/**
Expand All @@ -33,14 +33,14 @@ export abstract class ChatClientEventEmitter {
* Emit loading state changed event
*/
loadingChanged(isLoading: boolean): void {
this.emitEvent('client:loading-changed', { isLoading })
this.emitEvent('client:loading:changed', { isLoading })
}

/**
* Emit error state changed event
*/
errorChanged(error: string | null): void {
this.emitEvent('client:error-changed', {
this.emitEvent('client:error:changed', {
error,
})
}
Expand All @@ -49,12 +49,8 @@ export abstract class ChatClientEventEmitter {
* Emit text update events (combines processor and client events)
*/
textUpdated(streamId: string, messageId: string, content: string): void {
this.emitEvent('processor:text-updated', {
this.emitEvent('text:chunk:content', {
streamId,
content,
})

this.emitEvent('client:assistant-message-updated', {
messageId,
content,
})
Expand All @@ -71,15 +67,8 @@ export abstract class ChatClientEventEmitter {
state: string,
args: string,
): void {
this.emitEvent('processor:tool-call-state-changed', {
this.emitEvent('tools:call:updated', {
streamId,
toolCallId,
toolName,
state,
arguments: args,
})

this.emitEvent('client:tool-call-updated', {
messageId,
toolCallId,
toolName,
Expand All @@ -91,22 +80,6 @@ export abstract class ChatClientEventEmitter {
/**
* Emit tool result state change event
*/
toolResultStateChanged(
streamId: string,
toolCallId: string,
content: string,
state: string,
error?: string,
): void {
this.emitEvent('processor:tool-result-state-changed', {
streamId,
toolCallId,
content,
state,
error,
})
}

/**
* Emit thinking update event
*/
Expand All @@ -116,7 +89,7 @@ export abstract class ChatClientEventEmitter {
content: string,
delta?: string,
): void {
this.emitEvent('stream:chunk:thinking', {
this.emitEvent('text:chunk:thinking', {
streamId,
messageId,
content,
Expand All @@ -128,13 +101,15 @@ export abstract class ChatClientEventEmitter {
* Emit approval requested event
*/
approvalRequested(
streamId: string,
messageId: string,
toolCallId: string,
toolName: string,
input: any,
input: unknown,
approvalId: string,
): void {
this.emitEvent('client:approval-requested', {
this.emitEvent('tools:approval:requested', {
streamId,
messageId,
toolCallId,
toolName,
Expand All @@ -146,26 +121,34 @@ export abstract class ChatClientEventEmitter {
/**
* Emit message appended event
*/
messageAppended(uiMessage: UIMessage): void {
const contentPreview = uiMessage.parts
.filter((p) => p.type === 'text')
.map((p) => (p as any).content)
messageAppended(uiMessage: UIMessage, streamId?: string): void {
const content = uiMessage.parts
.filter((part) => part.type === 'text')
.map((part) => part.content)
.join(' ')
.substring(0, 100)

this.emitEvent('client:message-appended', {
this.emitEvent('text:message:created', {
streamId,
messageId: uiMessage.id,
role: uiMessage.role,
contentPreview,
content,
parts: uiMessage.parts,
})
}

/**
* Emit message sent event
*/
messageSent(messageId: string, content: string): void {
this.emitEvent('client:message-sent', {
this.emitEvent('text:message:created', {
messageId,
role: 'user',
content,
})

this.emitEvent('text:message:user', {
messageId,
role: 'user',
content,
})
}
Expand All @@ -190,7 +173,7 @@ export abstract class ChatClientEventEmitter {
* Emit messages cleared event
*/
messagesCleared(): void {
this.emitEvent('client:messages-cleared')
this.emitEvent('client:messages:cleared')
}

/**
Expand All @@ -199,10 +182,10 @@ export abstract class ChatClientEventEmitter {
toolResultAdded(
toolCallId: string,
toolName: string,
output: any,
output: unknown,
state: string,
): void {
this.emitEvent('tool:result-added', {
this.emitEvent('tools:result:added', {
toolCallId,
toolName,
output,
Expand All @@ -218,7 +201,7 @@ export abstract class ChatClientEventEmitter {
toolCallId: string,
approved: boolean,
): void {
this.emitEvent('tool:approval-responded', {
this.emitEvent('tools:approval:responded', {
approvalId,
toolCallId,
approved,
Expand All @@ -235,14 +218,19 @@ export class DefaultChatClientEventEmitter extends ChatClientEventEmitter {
*/
protected emitEvent(eventName: string, data?: Record<string, any>): void {
// For client:* and tool:* events, automatically add clientId and timestamp
if (eventName.startsWith('client:') || eventName.startsWith('tool:')) {
if (
eventName.startsWith('client:') ||
eventName.startsWith('tools:') ||
eventName.startsWith('text:')
) {
aiEventClient.emit(eventName as any, {
...data,
clientId: this.clientId,
source: 'client',
timestamp: Date.now(),
})
} else {
// For other events (e.g., processor:*), just add timestamp
// For other events, just add timestamp
aiEventClient.emit(eventName as any, {
...data,
timestamp: Date.now(),
Expand Down
Loading
Loading