Skip to content

Integrations#

Experimental: The AI extension and its component APIs may change in the future. Please give us feedback on your experience with them.

Eufemia provides the interface. Your application or chat framework owns message storage, requests, streaming, retries and tool execution. Map that state to the components where you render the chat.

Component contract#

ComponentPass from your application
Ai.Messagefrom, a unique DOM id, and children. Plain string children preserve line breaks.
Ai.ResponseMarkdown as children. Set parseIncompleteMarkdown while that text is streaming; set it to false when complete, stopped or failed.
Ai.PromptInputisBusy while a reply is pending or streaming, onSubmit to send, and onStop to cancel.
Ai.MessageIndicatorRender while waiting for the first response, then remove it.
Ai.TaskA human-readable title, status, optional errorText and result UI as children.
Ai.SourcesReferences as { url, title } objects, placed where you want them in the message.

See Usage for a complete composition without a chat framework. Use AI SDK or TanStack AI below to connect the same components to a framework. No chat SDK is required by Eufemia.

Streaming, completion and stopping#

Track whether each response is still receiving content. Completed messages keep parseIncompleteMarkdown={false} even when a later answer starts streaming. Stopping ends the busy UI and the unfinished-markdown treatment. The framework determines whether cancellation also reaches your server and any running tools. Keep tool status tied to the server outcome: stopping the stream alone does not confirm that a tool was canceled. Set canceled when your application receives that outcome.

Show a user-facing request error with GlobalStatus. Keep any partial answer visible. Tool errors belong to the individual Ai.Task with status="error" and a useful errorText. Use textareaProps.status for input validation, such as exceeding an application-specific limit.

Screen reader announcements#

The application owns announcements because it knows the request lifecycle. The conversation is a live log by default; text updates inside a streaming message are not guaranteed to be read in full. For completed-reply announcements, set aria-live="off" on Conversation and keep a separate AriaLive mounted outside it. Do not make every streaming token a live announcement.

This application helper waits for a busy-to-idle transition. It does not announce restored history and clears its region when another operation starts, allowing an identical reply to be announced again. Pass a readable localized summary as text, or the latest rendered message’s DOM messageId to read its visible content without Markdown syntax. Include relevant tool results. On cancellation or failure, include that outcome and useful partial content; avoid duplicating an error already announced by GlobalStatus. This helper is example application code, not an Eufemia export.

import { useEffect, useRef, useState } from 'react'
import AriaLive from '@dnb/eufemia/components/AriaLive'
// The application knows when a reply ends, including when it stops early.
export default function ReplyAnnouncement({
text,
isBusy,
messageId,
outcome,
}: {
text?: string
isBusy: boolean
messageId?: string
outcome?: string
}) {
const wasBusy = useRef(false)
const [announcement, setAnnouncement] = useState('')
useEffect(() => {
if (isBusy) {
setAnnouncement('')
} else if (wasBusy.current) {
const content = messageId
? document
.getElementById(messageId)
?.querySelector<HTMLElement>('.dnb-ai-message__content')
: null
const reply =
content?.innerText || content?.textContent || text || ''
setAnnouncement(
outcome && !reply.includes(outcome)
? [outcome, reply].filter(Boolean).join(' ')
: reply
)
}
wasBusy.current = isBusy
}, [isBusy, text, messageId, outcome])
return <AriaLive>{announcement}</AriaLive>
}

Keep isBusy true from submission until completion, cancellation or failure. If your framework briefly reports idle between tool steps, use the application's operation state instead. Only pass text from the current assistant turn. The recipes below read the latest assistant message from the rendered DOM. Pass a localized outcome after cancellation, and show that same outcome beside the partial answer. Adapt this to your message types and request errors. Test with the screen readers your application supports.

Messages that arrive without a request, such as a customer-service reply or a proactive message, need their own application announcement. Announce new incoming messages by stable id, and do not announce restored history or every streamed text update. Keep this separate from the busy-to-idle helper above.

See the live announcement demo.

Task statuses and confirmation#

StatusMeaning
runningWork is pending or in progress.
awaitingA decision from the user is required.
doneWork completed successfully.
errorWork failed.
canceledWork was canceled or permission was declined.

An Ai.Task only displays status and children. Map your framework's tool calls to a task; Eufemia does not run the tool. For a tool requiring approval, render Ai.Confirmation or your own confirmation controls as children and send the decision through your framework's approval API. Only show done when execution has completed. The examples below display approval states; add your application's decision controls before enabling tools that require approval.

Choosing Task or AgentAction#

Both components show work in progress and its outcome. Choose based on the interaction you need, rather than whether the operation comes from a framework tool or an agent. A lookup, report or payment can use either presentation.

NeedUse
A status row with details, structured results or decision controls below itAi.Task
An action label and target with known or unknown progress, followed by a short result and optional UndoAi.AgentAction
A decision before proceedingAi.Confirmation, on its own or inside Task

Task accepts arbitrary React children and has an awaiting state. AgentAction takes a text result and adds progress semantics, optional agent/target visuals and an Undo lifecycle. Neither component executes work, and a confirmed decision does not mean the resulting work is done.

A lookup with a structured result#

Use Task when the result needs its own content. For example, show a balance after the lookup completes:

import { Task } from '@dnb/eufemia/extensions/ai'
import { NumberFormat, P } from '@dnb/eufemia'
import '@dnb/eufemia/extensions/ai/style'
render(
<Task title="Checking your account balance" status="done">
<P space={0}>
Available:{' '}
<NumberFormat.Currency currency="NOK">12400</NumberFormat.Currency>
</P>
</Task>
)

Update the same Task from running to done when the application receives the result. For a decision, use awaiting with Confirmation as children, then move to running if the accepted decision starts work. See Task states.

An action with progress and Undo#

Use AgentAction when the interaction centers on progress and a short outcome. For a payment, the application supplies the actual progress while running, then the completed result and an Undo callback only if reversal is supported. See known progress and timed Undo for an interactive example. Its timers simulate an application; production progress and completion must come from the action itself.

Usually render one component for an operation. Avoid showing Task and AgentAction with duplicate status text for the same work. If a flow changes presentation, keep a stable operation identity and ensure removing decision controls does not lose keyboard focus.

Announcements#

Task has no live region; the application supplies meaningful status/result announcements. AgentAction announces status, result text and Undo availability by default. Progress and countdown ticks are not announced. Set announce={false} when displaying historical AgentActions or when the application already announces the operation.

Confirmation also announces decision-state changes by default. Choose one announcement owner for each update, including any surrounding conversation live region. Avoid announcing the same result through both a component and the completed-reply helper.

AI SDK#

The example uses ai@7.0.128 and @ai-sdk/react@4.0.131. Install them in your application, following the AI SDK setup guide. Your /api/chat endpoint must return the SDK's UI message stream; see its chatbot guide.

The following client renders text, tool status/results and source URLs. A server tool named getBalance returns { balance: number } in this example. Replace that result renderer with your application's typed tool UI. For approval controls, use tool approval and the hook's addToolApprovalResponse.

import { useId, useState } from 'react'
import ReplyAnnouncement from './ReplyAnnouncement'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, getToolName, isToolUIPart } from 'ai'
import type { DynamicToolUIPart, ToolUIPart, UIMessage } from 'ai'
import { GlobalStatus } from '@dnb/eufemia/components'
import { P } from '@dnb/eufemia/elements'
import {
Conversation,
Message,
MessageIndicator,
PromptInput,
Response,
Sources,
Task,
} from '@dnb/eufemia/extensions/ai'
import type { AiTaskStatus } from '@dnb/eufemia/extensions/ai'
import '@dnb/eufemia/extensions/ai/style'
const transport = new DefaultChatTransport({ api: '/api/chat' })
function taskStatus(part: ToolUIPart | DynamicToolUIPart): AiTaskStatus {
switch (part.state) {
case 'approval-requested':
return 'awaiting'
case 'approval-responded':
return part.approval.approved ? 'running' : 'canceled'
case 'output-available':
return 'done'
case 'output-error':
return 'error'
case 'output-denied':
return 'canceled'
default:
return 'running'
}
}
function BalanceResult({ output }: { output: unknown }) {
if (
typeof output !== 'object' ||
output === null ||
!('balance' in output) ||
typeof output.balance !== 'number'
) {
return <P>The balance is unavailable.</P>
}
return <P>Balance: {output.balance} NOK</P>
}
function MessageContent({
message,
isBusy,
}: {
message: UIMessage
isBusy: boolean
}) {
if (message.role === 'user') {
return (
<span style={{ whiteSpace: 'pre-wrap' }}>
{message.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('\n\n')}
</span>
)
}
return (
<>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return (
<Response
key={index}
parseIncompleteMarkdown={isBusy && part.state !== 'done'}
>
{part.text}
</Response>
)
}
if (isToolUIPart(part)) {
const status = taskStatus(part)
const name = getToolName(part)
return (
<Task
key={part.toolCallId}
title={
part.title ??
(name === 'getBalance' ? 'Checking your balance' : name)
}
status={status}
errorText="The tool could not complete. Try again."
>
{name === 'getBalance' &&
part.state === 'output-available' && (
<BalanceResult output={part.output} />
)}
</Task>
)
}
return null
})}
<Sources
sources={message.parts.flatMap((part) =>
part.type === 'source-url'
? [{ url: part.url, title: part.title }]
: []
)}
/>
</>
)
}
export function Chat() {
const prefix = useId()
const [stoppedIds, setStoppedIds] = useState<string[]>([])
const { messages, status, error, sendMessage, stop } = useChat({
transport,
})
const isBusy = status === 'submitted' || status === 'streaming'
const latest = messages.at(-1)
return (
<>
<Conversation
aria-live="off"
label="Chat with the assistant"
style={{ height: '32rem' }}
>
{messages
.filter((message) => message.role !== 'system')
.map((message, index, visible) => (
<Message
key={message.id}
id={prefix + message.id}
from={message.role === 'user' ? 'user' : 'assistant'}
>
{stoppedIds.includes(message.id) && <P>Reply stopped.</P>}
<MessageContent
message={message}
isBusy={isBusy && index === visible.length - 1}
/>
</Message>
))}
{status === 'submitted' && <MessageIndicator />}
</Conversation>
<ReplyAnnouncement
isBusy={isBusy}
messageId={
latest?.role === 'assistant' ? prefix + latest.id : undefined
}
outcome={
latest && stoppedIds.includes(latest.id)
? 'Reply stopped.'
: undefined
}
/>
{error && (
<GlobalStatus
show
autoScroll={false}
omitSetFocus
id={prefix + '-error'}
text="The reply could not be completed. Try again."
/>
)}
<PromptInput
isBusy={isBusy}
onSubmit={({ value }) => {
void sendMessage({ text: value })
}}
onStop={() => {
if (latest?.role === 'assistant') {
setStoppedIds((ids) => [...ids, latest.id])
}
void stop()
}}
/>
</>
)
}

TanStack AI#

The example uses @tanstack/ai@0.64.1 and @tanstack/ai-react@0.29.4. Install them in your application and create the streaming /api/chat endpoint from the TanStack AI quick start.

Text parts use content. Tool calls and results are separate records: pair them by call id and render a result once. This example uses getBalance as above and accepts validated citation data from the application through sourcesByMessage. TanStack source metadata depends on your provider and application; normalize it to { url, title } when receiving the response. For user decisions, connect the framework's interrupts to confirmation controls inside Ai.Task.

import { useId, useState } from 'react'
import ReplyAnnouncement from './ReplyAnnouncement'
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
import type { UIMessage } from '@tanstack/ai-react'
import { GlobalStatus } from '@dnb/eufemia/components'
import { P } from '@dnb/eufemia/elements'
import {
Conversation,
Message,
MessageIndicator,
PromptInput,
Response,
Sources,
Task,
} from '@dnb/eufemia/extensions/ai'
import type { AiSource, AiTaskStatus } from '@dnb/eufemia/extensions/ai'
import '@dnb/eufemia/extensions/ai/style'
const connection = fetchServerSentEvents('/api/chat')
type ToolCall = Extract<UIMessage['parts'][number], { type: 'tool-call' }>
type ToolResult = Extract<
UIMessage['parts'][number],
{ type: 'tool-result' }
>
function taskStatus(
part: ToolCall,
result: ToolResult | undefined
): AiTaskStatus {
if (
part.approval?.approved === false ||
result?.outcome === 'denied' ||
result?.outcome === 'cancelled'
) {
return 'canceled'
}
if (part.state === 'error' || result?.state === 'error') {
return 'error'
}
if (part.state === 'approval-requested') {
return 'awaiting'
}
if (part.state === 'complete' || result?.state === 'complete') {
return 'done'
}
return 'running'
}
function BalanceResult({ output }: { output: unknown }) {
if (
typeof output !== 'object' ||
output === null ||
!('balance' in output) ||
typeof output.balance !== 'number'
) {
return <P>The balance is unavailable.</P>
}
return <P>Balance: {output.balance} NOK</P>
}
function MessageContent({
message,
isBusy,
}: {
message: UIMessage
isBusy: boolean
}) {
if (message.role === 'user') {
return (
<span style={{ whiteSpace: 'pre-wrap' }}>
{message.parts
.filter((part) => part.type === 'text')
.map((part) => part.content)
.join('\n\n')}
</span>
)
}
return (
<>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return (
<Response
key={index}
parseIncompleteMarkdown={
isBusy && index === message.parts.length - 1
}
>
{part.content}
</Response>
)
}
if (part.type === 'tool-call') {
const result = message.parts.find(
(candidate): candidate is ToolResult =>
candidate.type === 'tool-result' &&
candidate.toolCallId === part.id
)
const status = taskStatus(part, result)
return (
<Task
key={part.id}
title={
part.name === 'getBalance'
? 'Checking your balance'
: part.name
}
status={status}
errorText="The tool could not complete. Try again."
>
{part.name === 'getBalance' && status === 'done' && (
<BalanceResult output={part.output} />
)}
</Task>
)
}
if (
part.type === 'tool-result' &&
!message.parts.some(
(candidate) =>
candidate.type === 'tool-call' &&
candidate.id === part.toolCallId
)
) {
return (
<Task
key={part.toolCallId}
title={part.name ?? 'Tool result'}
status={
part.outcome === 'denied' || part.outcome === 'cancelled'
? 'canceled'
: part.state === 'error'
? 'error'
: part.state === 'complete'
? 'done'
: 'running'
}
errorText="The tool could not complete. Try again."
>
{part.state === 'complete' &&
typeof part.content === 'string' && <P>{part.content}</P>}
</Task>
)
}
return null
})}
</>
)
}
// Supply references from your application's validated citation data, keyed by message id.
export function Chat({
sourcesByMessage = {},
}: {
sourcesByMessage?: Record<string, Array<AiSource>>
}) {
const prefix = useId()
const [stoppedIds, setStoppedIds] = useState<string[]>([])
const { messages, isLoading, error, status, sendMessage, stop } =
useChat({ connection })
const latest = messages.at(-1)
return (
<>
<Conversation
aria-live="off"
label="Chat with the assistant"
style={{ height: '32rem' }}
>
{messages
.filter((message) => message.role !== 'system')
.map((message, index, visible) => (
<Message
key={message.id}
id={prefix + message.id}
from={message.role === 'user' ? 'user' : 'assistant'}
>
{stoppedIds.includes(message.id) && <P>Reply stopped.</P>}
<MessageContent
message={message}
isBusy={isLoading && index === visible.length - 1}
/>
<Sources sources={sourcesByMessage[message.id]} />
</Message>
))}
{status === 'submitted' && <MessageIndicator />}
</Conversation>
<ReplyAnnouncement
isBusy={isLoading}
messageId={
latest?.role === 'assistant' ? prefix + latest.id : undefined
}
outcome={
latest && stoppedIds.includes(latest.id)
? 'Reply stopped.'
: undefined
}
/>
{error && (
<GlobalStatus
show
autoScroll={false}
omitSetFocus
id={prefix + '-error'}
text="The reply could not be completed. Try again."
/>
)}
<PromptInput
isBusy={isLoading}
onSubmit={({ value }) => {
void sendMessage(value)
}}
onStop={() => {
if (latest?.role === 'assistant') {
setStoppedIds((ids) => [...ids, latest.id])
}
stop()
}}
/>
</>
)
}

Other content and application responsibilities#

These examples deliberately render text and tool results. Add application renderers for attachments, images, structured data and other message types you support. Keep system messages and non-displayable model metadata out of the visible conversation. Translate the example labels and messages to your application's supported languages.

Use unique DOM ids across chat instances, including when restoring older conversations. The recipes prefix the framework's stable message id with React's useId. Pass the same resulting DOM id to scrollToMessage. See Message scroller for loading history and reading-position controls.

Authentication, authorization, persistence and approval of consequential tool actions belong to your application. Keep SDK objects in your application and pass only the display props and children Eufemia needs. The recipes are examples to adapt; Eufemia does not ship adapters or promise compatibility with every SDK version.

Suggest an edit