Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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`
Comment thread
AlemTuzlak marked this conversation as resolved.
- 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 {
Comment thread
AlemTuzlak marked this conversation as resolved.
// 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