---
title: 'Integrations'
description: 'Compose a chat UI and connect AI SDK or TanStack AI to Eufemia.'
version: 11.17.0
generatedAt: 2026-11-05T09:55:47.096Z
checksum: 090b7d977ba4be5e2c4c04d199a30a4048416c59f443a56985df2f80629d9c40
---

# 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

| Component             | Pass from your application                                                                                                          |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `Ai.Message`          | `from`, a unique DOM `id`, and children. Plain string children preserve line breaks.                                                |
| `Ai.Response`         | Markdown as children. Set `parseIncompleteMarkdown` while that text is streaming; set it to false when complete, stopped or failed. |
| `Ai.PromptInput`      | `isBusy` while a reply is pending or streaming, `onSubmit` to send, and `onStop` to cancel.                                         |
| `Ai.MessageIndicator` | Render while waiting for the first response, then remove it.                                                                        |
| `Ai.Task`             | A human-readable `title`, `status`, optional `errorText` and result UI as children.                                                 |
| `Ai.Sources`          | References as `{ url, title }` objects, placed where you want them in the message.                                                  |

See [Usage](/web/patterns-and-extensions/ai/#usage) for a complete composition without a chat framework. Use [AI SDK](#ai-sdk) or [TanStack AI](#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](/web/components/global-status/). 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.

```tsx
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](/web/patterns-and-extensions/ai/demos/#streamed-reply-announcements).

## Task statuses and confirmation

| Status     | Meaning                                       |
| ---------- | --------------------------------------------- |
| `running`  | Work is pending or in progress.               |
| `awaiting` | A decision from the user is required.         |
| `done`     | Work completed successfully.                  |
| `error`    | Work failed.                                  |
| `canceled` | Work 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](/web/patterns-and-extensions/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.

| Need                                                                                                    | Use                                                                                         |
| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| A status row with details, structured results or decision controls below it                             | [Ai.Task](/web/patterns-and-extensions/ai/Task/)                                            |
| An action label and target with known or unknown progress, followed by a short result and optional Undo | [Ai.AgentAction](/web/patterns-and-extensions/ai/AgentAction/)                              |
| A decision before proceeding                                                                            | [Ai.Confirmation](/web/patterns-and-extensions/ai/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:

```tsx
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](/web/patterns-and-extensions/ai/Task/demos/#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](/web/patterns-and-extensions/ai/AgentAction/demos/#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](#screen-reader-announcements).

## 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](https://ai-sdk.dev/docs/getting-started). Your `/api/chat` endpoint must return the SDK's UI message stream; see its [chatbot guide](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot).

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](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage) and the hook's `addToolApprovalResponse`.

```tsx
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](https://tanstack.com/ai/latest/docs/getting-started/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](https://tanstack.com/ai/latest/docs/ui/react) to confirmation controls inside `Ai.Task`.

```tsx
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](/web/patterns-and-extensions/ai/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.
