Skip to content
omaris

Communication

AI Chat

A conversation with a model — the thread, the streaming reply, the composer, the stop button — wired to whichever model you have.

import { AiChat } from 'omaris'
Learn

Examples

Basic

One prop. mockChat() stands in for a model on this page; in an app, chatEndpoint('/api/chat') points it at a route of yours and the rest is the same — the chips, the streaming reply, the stop button, the markdown, the thread that follows the reply until you scroll up.

How can I help?

<script lang="ts">	import { AiChat, mockChat } from 'omaris';</script>​<AiChat	send={mockChat()}	suggestions={[		'Explain runes in one paragraph',		'Write a Svelte counter',		'What makes a chat feel right?'	]}/>

Assistant

An assistant with a job. title and hint set the empty state, avatar puts its mark beside each reply, assistantName is what screen readers call it, and disclaimer sits under the composer.

Hi, I'm the order helper

I can find an order, or draft a reply to a customer.

Answers can be wrong. Check anything that matters.
<script lang="ts">	import { AiChat, mockChat } from 'omaris';</script>​<AiChat	send={mockChat()}	avatar	assistantName="Order helper"	title="Hi, I'm the order helper"	hint="I can find an order, or draft a reply to a customer."	suggestions={['Where is order #4821?', 'Draft a refund reply']}	disclaimer="Answers can be wrong. Check anything that matters."/>

History

A conversation resumed. bind:messages is the whole state — load it from wherever you keep it, and save it in onchange. The reply's markdown is rendered, code blocks included, with copy on each.

You said:
How do I stream a reply?
Assistant said:

Return the Response from your fetch as is. The component reads the AI SDK stream, plain text, or JSON — whichever the route sends.

send={(messages, { signal }) =>  fetch('/api/chat', { method: 'POST', body: JSON.stringify({ messages }), signal })}
<script lang="ts">	import { AiChat, mockChat, type AiChatMessage } from 'omaris';​	let messages = $state<AiChatMessage[]>([		{ id: '1', role: 'user', content: 'How do I stream a reply?', createdAt: 1, status: 'done' },		{			id: '2',			role: 'assistant',			content:				"Return the `Response` from your `fetch` as is. The component reads the AI SDK stream, plain text, or JSON — whichever the route sends.\n\n```ts\nsend={(messages, { signal }) =>\n  fetch('/api/chat', { method: 'POST', body: JSON.stringify({ messages }), signal })}\n```",			createdAt: 2,			status: 'done'		}	]);</script>​<AiChat send={mockChat()} bind:messages />

Error

When it fails. The message lands where the reply would have been, with a retry; what streamed before the failure stays. The stop button keeps what arrived rather than throwing it away.

How can I help?

Every second reply fails here, on purpose.

<script lang="ts">	import { AiChat, mockChat } from 'omaris';</script>​<AiChat	send={mockChat({ fail: 2, latency: 400 })}	hint="Every second reply fails here, on purpose."	suggestions={['Say hello', 'Say it again']}	height={360}/>

Headless

Your own composer. An empty composer snippet takes the bar away; bind:this gives you send(), stop(), regenerate() and clear(), and bind:status says where it is.

How can I help?

<script lang="ts">	import { AiChat, Button, Input, mockChat, type AiChatStatus } from 'omaris';​	let chat = $state<AiChat | null>(null);	let draft = $state('');	let status = $state<AiChatStatus>('idle');</script>​<div class="flex flex-col gap-3">	<AiChat bind:this={chat} bind:status send={mockChat()} height={320}>		{#snippet composer()}{/snippet}	</AiChat>	<form		class="flex items-end gap-2"		onsubmit={(event) => {			event.preventDefault();			void chat?.send(draft);			draft = '';		}}	>		<Input label="Message" bind:value={draft} class="flex-1" />		{#if status === 'streaming'}			<Button type="button" variant="tonal" onclick={() => chat?.stop()}>Stop</Button>		{:else}			<Button type="submit" disabled={!draft.trim()}>Send</Button>		{/if}		<Button type="button" variant="text" tone="secondary" onclick={() => chat?.clear()}>			Clear		</Button>	</form></div>

Server

The real wiring. The key stays on the server: chatEndpoint() posts the conversation to a route of yours and streams the reply back. With omaris/ai and the Vercel AI SDK the route is two lines, and switching models is switching the import: ``ts // src/routes/api/chat/+server.ts import { chatRoute } from 'omaris/ai'; import { anthropic } from '@ai-sdk/anthropic'; // import { openai } from '@ai-sdk/openai'; → openai('gpt-5') // import { google } from '@ai-sdk/google'; → google('gemini-2.5-pro') export const POST = chatRoute({ model: anthropic('claude-sonnet-5'), system: 'You are the help desk for a bakery. Be brief.' }); ` Any other route works too: the component reads the AI SDK's stream, plain streamed text, or a JSON { text }. send can also be your own function that returns a fetch Response`, a string, or an async iterable.

How can I help?

<script lang="ts">	import { AiChat, chatEndpoint } from 'omaris';</script>​<AiChat	send={chatEndpoint('/api/chat', { body: { conversation: 'demo' } })}	system="Answer in one short paragraph."/>

Overridden

Every part is reachable. classes names the slots, class covers the root and wins last, height="100%" lets a parent set the frame, and --chat-measure narrows the reading column. The header snippet sits above the thread and the icon snippet replaces the assistant's mark.

Docs assistant online

Ask the docs

Answers cite the page they came from.

<script lang="ts">	import { AiChat, Badge, mockChat } from 'omaris';</script>​<div class="h-112 max-w-md">	<AiChat		send={mockChat()}		tone="tertiary"		height="100%"		avatar		title="Ask the docs"		hint="Answers cite the page they came from."		placeholder="Ask about the design system…"		class="rounded-shape-lg bg-surface-container-lowest [--chat-measure:32rem]"		classes={{			bubble: 'rounded-shape-md bg-tertiary-container text-tertiary-container-foreground',			composer: 'rounded-shape-lg',			tools: 'pointer-fine:opacity-100'		}}	>		{#snippet header()}			<span class="text-sm font-medium text-foreground">Docs assistant</span>			<Badge tone="success" variant="tonal" size="sm">online</Badge>		{/snippet}		{#snippet icon()}			...		{/snippet}	</AiChat></div>

When to use it

Use it for

  • A conversation with a model, anywhere one belongs: a help desk in a drawer, an assistant in a dashboard's sidebar, a whole chat page. One send prop and it streams, renders markdown, scrolls, stops and retries.
  • An assistant with a job. system says what it is for, title and hint greet the person, suggestions are the three things people ask it.
  • A conversation that comes back. bind:messages is the whole state; save it in onchange, load it on the way in.
  • A reply that shows its work. Reasoning a model streams is folded above its answer, with nothing to turn on.
  • A composer of your own. An empty composer snippet takes the bar away, and send(), stop(), regenerate() and clear() come through bind:this.

Not for

  • Chat between people → List with your own transport. This one has a person and a model, and the tools on a message (regenerate, edit) assume it.
  • One question with one answer, with no thread → a Text Field and a Card for the reply.
  • Making a picture → AI Image. A model that replies with one can still be shown here through the message snippet.
  • Long-form output to be edited → Rich Text Editor, with the model writing into it.

Do

  • Keep the key on the server. chatEndpoint('/api/chat') here and chatRoute() from omaris/ai in the route, or any route that streams. The component reads the AI SDK's stream, plain streamed text, or a JSON { text }.
  • Return the fetch Response as is from a send of your own. There is nothing to parse on your side.
  • Give it a height it can scroll in. height or class="h-full" inside a flex column; a chat that grows with its thread pushes the composer off the screen.
  • Put a disclaimer under it when the answers matter.
  • Leave the reading column alone in a wide frame. It stops at 48rem and centres, which is what keeps a reply readable on a full page; set --chat-measure on the root only for a different measure.
  • Use mockChat() while the route is not there yet. It streams markdown a word at a time after a pause, and can fail on demand.

Don't

  • Fight the scroll from outside. The thread follows the reply until the person scrolls up, and the jump button brings them back; a scrollTo of your own on every change drags them down mid-read.
  • Put the model's reply in a bubble through classes.bubble. That slot is the person's message; the reply is plain text on purpose, so long answers and code blocks read like a document.
  • Store secrets in body of chatEndpoint(). It is the request body, in the network tab.
  • Drop status: 'error' messages from messages before resending them. chatEndpoint() already leaves them out.

Quick reference

tone
  • primary (default)
  • secondary
  • tertiary
  • destructive
  • success
  • warning
  • info

API

AiChat

A conversation with a model — the thread, the streaming reply, the composer, the stop button — wired to whichever model you have.

The smallest call is one prop:

The person's messages sit in a compact bubble on the end side; the model's reply runs as plain text the width of the column, markdown and code blocks rendered. The thread follows the reply as it streams until the person scrolls up, and a button brings them back. Replies can be stopped, retried, regenerated and copied, and the person's own messages edited. send is any (messages, ctx) => reply, and a reply can be a string, an async iterable, a ReadableStream or the raw Response from a fetch — the component reads all of them.

The column is 48rem at most, centred; set --chat-measure on the root for another width.

import { AiChat } from 'omaris'
<AiChat send={chatEndpoint('/api/chat')} />

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
No description in the source yet.
header
Above the thread — a title, a model picker. Only rendered with a header snippet.
thread
The scrolling area, full width so its scrollbar sits at the edge.
content
The centred column inside the thread.
user
One of the person's messages, on the end side: the bubble, and its tools — beside it on hover with a mouse, under it on touch.
bubble
The bubble around the person's message.
assistant
One reply: the optional avatar and the text column.
avatar
The circle beside a reply, with avatar.
reply
The reply's column: reasoning, text, error, tools.
prose
Rendered markdown.
cursor
The dot at the end of a reply still arriving.
typing
The three dots before the first word.
tools
The copy / regenerate / edit row under a message.
reasoning
The model's reasoning, folded above its answer when it sent any.
alert
A failed reply's message and retry.
empty
Before the first message.
mark
The circle above the headline.
title
No description in the source yet.
description
No description in the source yet.
suggestions
No description in the source yet.
footer
The composer, the jump button over it and the disclaimer under it.
jump
The "jump to latest" button, floating over the composer.
composer
The single rounded surface: text field and send button.
input
No description in the source yet.
disclaimer
The line under the composer: "Can make mistakes".

Props

send
SendFn

Answers the conversation. chatEndpoint('/api/chat'), mockChat(), or any (messages, ctx) => reply of your own — a fetch whose Response you return as is will do.

messages bindable

Defaults to []

AiChatMessage[]

The conversation. Bindable; give it a history to resume one.

prompt bindable

Defaults to ''

string

The text in the composer. Bindable.

system
string

A system prompt, carried in the request by chatEndpoint().

status bindable

Defaults to 'idle'

AiChatStatus

Where it is. Bindable, read-only in practice.

tone

Defaults to 'primary'

AiChatTone

Colours the send button, the focus ring, links and the assistant's mark.

primary
secondary
tertiary
destructive
success
warning
info
height

Defaults to '32rem'

number | string

Height of the whole thing, in px or any CSS length. class="h-full" also works.

avatar

Defaults to false

boolean

A small mark beside each reply. The icon snippet replaces what is in it.

suggestions

Defaults to []

string[]

Prompts offered as chips before the first message.

assistantName

Defaults to 'Assistant'

string

The name the assistant goes by, for screen readers.

title

Defaults to 'How can I help?'

string

The headline before the first message.

hint
string

The line under it.

placeholder

Defaults to 'Message…'

string
disclaimer
string

The line under the composer.

disabled

Defaults to false

boolean
onsend
(message: AiChatMessage) => void

Called with the message the person sent.

onfinish
(message: AiChatMessage) => void

Called with the finished reply.

onerror
(error: Error) => void
onchange
(messages: AiChatMessage[]) => void

Called after any change to the conversation.

class
string
classes
AiChatClasses

Per-part Tailwind overrides. class still covers the root.

header
Snippet

Above the thread.

icon
Snippet

The assistant's mark, in the empty state and the avatar. Defaults to sparkles.

empty
Snippet<[AiChatEmptyContext]>

Replaces the empty thread.

message
Snippet<[AiChatMessageContext]>

Replaces what a message shows — the bubble's text, or the reply's.

actions
Snippet<[AiChatMessageContext]>

Extra buttons under a message, after copy, regenerate and edit.

composer
Snippet<[AiChatComposerContext]>

Replaces the composer. An empty snippet takes it away.