@silo-software/chat
v0.4.2
Published
Silo reader chat component for external CMS sites
Downloads
1,798
Readme
@silo-software/chat
The Silo reader chat for external publication sites — the same thread, activity timeline, inline citations, sources list and composer readers see on Silo's hosted CMS site, packaged as a self-contained React component.
Silo runs the assistant turn and stores the conversation. The site only forwards messages and renders the stream it receives, so the publication app needs no AI runtime and no model keys. The reader token stays in an HttpOnly cookie of the site; the browser never calls Silo directly.
- React 19 peer dependency, ESM, no
@repo/*, no assistant-ui. - Tailwind utilities are compiled into
@silo-software/chat/styles.cssat build time and prefixed withsilo:(silo:flex,silo:hover:bg-muted), so they cannot collide with the host's own classes. - No preflight and no global resets: the host page keeps its own base styles.
Install
pnpm add @silo-software/chat @silo-software/sdk@silo-software/sdk is already a dependency of the package; it is listed here because the
site's route handlers use it.
Styles
Import the stylesheet once, from the layout or the page that renders the chat:
import '@silo-software/chat/styles.css';Colors, borders and radii come from the shadcn CSS variables the host already
defines (--background, --foreground, --card, --popover, --muted,
--accent, --primary, --border, --input, --ring, --radius), so the
host theme and its .dark class apply. Light fallbacks live in a zero-specificity
:where(:root) block and are only used when the host defines nothing.
The chat fills its container, so give the wrapper a height:
<main className="mx-auto flex h-[calc(100dvh-4rem)] w-full max-w-3xl flex-col">
<SiloChat chatId={chatId} initialMessages={messages} />
</main>Route handlers
SiloChat talks to the site's own routes. The default transport
(createFetchChatTransport({ basePath: '/api/chat' })) calls:
| Call | Request | Response |
| --- | --- | --- |
| Start a chat | POST /api/chat { articleSlug? } | { chatId } |
| Send a message | POST /api/chat/{chatId}/messages { message, articleSlug? } | text/event-stream |
| Stop the turn | POST /api/chat/{chatId}/stop | any 2xx |
Every SSE data: line is one record of cmsChatStreamEventSchema
(@silo-software/sdk/types): { type: 'message', message } carries the complete current
state of the turn's answer and replaces the previous one, { type: 'done' }
ends the turn, { type: 'error', message } reports a reader-safe failure.
A Next.js route handler set that forwards to Silo with the reader token:
// app/api/chat/route.ts
import { createCmsClient } from '@silo-software/sdk';
import { readReaderAccessToken } from '@/lib/reader-session';
export async function POST(request: Request) {
const body = (await request.json().catch(() => ({}))) as {
articleSlug?: string;
};
const client = createCmsClient({
baseUrl: process.env.SILO_API_URL!,
clientId: process.env.SILO_CLIENT_ID!,
serverSecret: process.env.SILO_SERVER_SECRET,
});
const session = await client.startChatSession(
{ articleSlug: body.articleSlug },
{ accessToken: await readReaderAccessToken() },
);
return Response.json(session);
}// app/api/chat/[chatId]/messages/route.ts
import { createCmsClient } from '@silo-software/sdk';
import { readReaderAccessToken } from '@/lib/reader-session';
export async function POST(
request: Request,
{ params }: { params: Promise<{ chatId: string }> },
) {
const { chatId } = await params;
const body = (await request.json()) as {
articleSlug?: string;
message: string;
};
const client = createCmsClient({
baseUrl: process.env.SILO_API_URL!,
clientId: process.env.SILO_CLIENT_ID!,
serverSecret: process.env.SILO_SERVER_SECRET,
});
const upstream = await client.sendChatMessage(
{
accessToken: await readReaderAccessToken(),
chatId,
message: body.message,
articleSlug: body.articleSlug,
},
{ signal: request.signal },
);
return new Response(upstream.body, {
headers: {
'cache-control': 'no-store',
'content-type': 'text/event-stream',
},
});
}// app/api/chat/[chatId]/stop/route.ts
import { createCmsClient } from '@silo-software/sdk';
import { readReaderAccessToken } from '@/lib/reader-session';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ chatId: string }> },
) {
const { chatId } = await params;
const client = createCmsClient({
baseUrl: process.env.SILO_API_URL!,
clientId: process.env.SILO_CLIENT_ID!,
serverSecret: process.env.SILO_SERVER_SECRET,
});
await client.stopChatTurn({
accessToken: await readReaderAccessToken(),
chatId,
});
return Response.json({ stopped: true });
}Answer a missing or expired reader session with 401; SiloChat turns that into
a "please sign in again" notice instead of an error loop.
Component
'use client';
import { SiloChat } from '@silo-software/chat';
import '@silo-software/chat/styles.css';
export function ArticleChat({
chatId,
messages,
articleSlug,
}: {
chatId: string | null;
messages: CmsChatMessageDto[];
articleSlug?: string;
}) {
return (
<SiloChat
articleSlug={articleSlug}
chatId={chatId}
getArticleHref={(slug) => `/artikel/${slug}`}
initialMessages={messages}
onChatStarted={(startedChatId) => {
window.history.replaceState(null, '', `/chat/${startedChatId}`);
}}
title="Chat"
/>
);
}| Prop | Type | Meaning |
| --- | --- | --- |
| chatId | string \| null | Silo chat session, null before the first message |
| initialMessages | CmsChatMessageDto[] | Messages the site's server already loaded |
| title | string \| null | Title shown above the thread |
| articleSlug | string | Article the reader opened the chat from |
| transport | SiloChatTransport | Defaults to the fetch transport on /api/chat |
| labels | Partial<SiloChatLabels> | Overrides parts of the German default copy |
| getArticleHref | (slug) => string | Link for article sources; defaults to the cited URL |
| onChatStarted | (chatId) => void | Called once Silo created the chat, so the URL can follow |
| className | string | Extra classes on the outer element |
German copy (defaultSiloChatLabels) is exported from @silo-software/chat for reuse.
Any subset can be overridden; templates use {index}, {title}, {count},
{query} and {seconds} placeholders.
Hook and transport
Sites that want their own layout can drive the state machine directly:
const { messages, status, error, send, stop, retry } = useSiloChat({
chatId,
initialMessages,
articleSlug,
transport: createFetchChatTransport({ basePath: '/api/chat' }),
onChatStarted: (id) => router.replace(`/chat/${id}`),
});status is idle | sending | streaming | error. A user message is added
optimistically; every message event replaces the assistant answer of that turn;
stop aborts the request and asks Silo to stop the turn. A custom transport only
has to implement startChat, sendMessage and stop
(SiloChatTransport).
What the component renders
- The reader's question in the muted bubble.
- The assistant's work steps behind a collapsed disclosure: web search rows with queries, result counts and result cards, plus one row per other step with its detail. While the turn runs the panel stays open and shows the live duration; afterwards it shows the finished work duration.
- The markdown answer (GFM tables, lists, code, images) with the numbered inline
citations
[n](url)as hover cards. - The collapsible sources list with the site's own article links.
- Copy, timestamps, the typing dots, the error state and the stopped-run state.
- The composer with send and stop only.
Not included: model or performance-tier selection, attachments, voice mode and dictation, generative UI, subagents, approvals, feedback, thread lists and project chats. These are admin or hosted-site features, and the reader chat API has no route for them.
