@skaile/forge-chat
@skaile/forge-chat
One chat window for every SKAILE frontend: a framework-free core (message model, reducer, transport port), Vue components on Nuxt UI, a skaile-protocol transport and a Vercel AI SDK transport with a server handler (text and image generation).
bun add @skaile/forge-chat
Peers: vue ^3.5 (required); @nuxt/ui ^4.11 for the Vue components; @skaile/workspaces ^5.6 (types) for the skaile transport; ai ^7 for the server handler; nuxt >=3 for the /nuxt module.
Entries
| Entry | What | Imports |
|---|---|---|
@skaile/forge-chat / /core |
ChatMessage/ChatPart model, ChatStreamEvent, reduceChat, createChatSession, ChatTransport, readSse, toolPrimaryArg |
nothing |
/vue |
ChatPanel, ChatSessionPanel, ChatParts, ChatMessageView, ChatComposer, part components, useChat, injection keys |
core, vue, Nuxt UI (templates) |
/transports/skaile |
createSkaileTransport, mapSkaileEvent, createWorkspacesClientLink |
core |
/transports/ai-sdk |
createAiSdkTransport (browser, never imports ai) |
core |
/server/ai-sdk |
createAiSdkChatHandler, toModelMessages, AiSdkChatContext, ClientToolDecls (Web Request → Response) |
ai, zod |
/nuxt |
Nuxt module: transpile + components-transform wiring for the Vue components | @nuxt/kit |
/styles/chat.css |
theme variables with fallbacks to Nuxt UI tokens | — |
Styling
Import @skaile/forge-chat/styles/chat.css once. The components use Tailwind utility classes; Tailwind v4 does not scan node_modules, so add the package to your sources:
@import "tailwindcss";
@import "@nuxt/ui";
@source "../../node_modules/@skaile/forge-chat/dist";
The variables --sk-border, --sk-muted, --sk-bg-elevated, --sk-fg-highlighted default to Nuxt UI's --ui-* tokens; a host theme that defines them wins.
Nuxt UI components in dist
The built components reference Nuxt UI by name (resolveComponent('UButton'), UIcon, …). Nuxt rewrites those calls into imports only for files its components transform includes, and node_modules is excluded by default. A Nuxt host adds the module, after @nuxt/ui:
// nuxt.config.ts
export default defineNuxtConfig({ modules: ['@nuxt/ui', '@skaile/forge-chat/nuxt'] })
It does what a host would otherwise write by hand:
build: { transpile: ['@skaile/forge-chat'] },
components: { transform: { include: [/@skaile[\\/]forge-chat[\\/]dist[\\/]/] } },
Without it the parts render as unknown <ubutton> elements. (A Nuxt module that ships forge-chat's components to its host does the same in its own setup.)
Model
A ChatMessage is a role plus ordered ChatParts: text, reasoning, tool, file, question, error, status, subagent, file-change, dev-server, cancel, and host parts data-<name>. Transports emit ChatStreamEvents; reduceChat(state, event, generateId) folds them into ChatState (messages, phase: 'idle' | 'submitted' | 'streaming', activity, error, pendingQuestion). See ADR 0001.
import { createChatSession } from '@skaile/forge-chat'
const session = createChatSession({ transport })
await session.load() // history, if the transport has one
await session.send('hello') // optimistic user message; false when busy/empty/rejected
session.state.subscribe(state => render(state))
session.push(event) reduces a host event (e.g. { type: 'part', role: 'system', part: { type: 'status', text: 'Model changed' } }).
ChatPanel
Controlled by props, so it renders a ChatSession (through ChatSessionPanel) or messages a host builds itself.
| Prop | Type |
|---|---|
messages |
readonly ChatMessage[] |
phase |
ChatPhase (busy = not idle) |
error, activity |
string | null |
pendingQuestion |
ChatState['pendingQuestion'] |
renderers |
Partial<Record<part type, Component>> — overrides builtins, renders data-* parts |
renderMarkdown |
(text) => html (default: escaped text, \n → <br>) |
disabled, placeholder, maxLength, autoScroll (default true) |
Emits send(input: ChatSendInput), abort(), answer(answer: string). Slots: header, empty, message-actions ({ message, text }), footer, composer ({ draft, setDraft, submit, busy, disabled }; default ChatComposer). Exposes setDraft, submit.
Part renderers receive { part, message, compact } (ChatPartProps). Consecutive tool parts render as one group; every part wrapper carries data-testid="chat-part-<type>".
<ChatSessionPanel :session="session" :renderers="{ 'data-demo-card': DemoCardPart }">
<template #empty>Ask anything.</template>
</ChatSessionPanel>
Skaile transport
A SkaileLink is the host side: how it posts prompts to its own server and receives agent events.
import { createSkaileTransport } from '@skaile/forge-chat/transports/skaile'
const transport = createSkaileTransport({
prompt: input => $fetch('/api/prompt', { method: 'POST', body: { message: input.text } }),
abort: () => $fetch('/api/abort', { method: 'POST' }),
onEvent: handler => stream.onEvent(handler),
history: async () => (await $fetch('/api/messages')).map(m => m.payload),
}, {
// consulted first for every event: events → used instead of the core mapping, null → core mapping
extend: event => event.type === 'demo_card' ? [{ type: 'data', name: 'demo-card', id: String(event.id), data: event }] : null,
})
createWorkspacesClientLink(client) adapts a @skaile/workspaces client (send(AgentCommand), onEvent); attachments must carry path.
AI SDK transport and server
Wire: POST <endpoint> with { messages, input, ...body }; the response is text/event-stream, one event: chunk frame per ChatStreamEvent, ending with turn-end.
// browser
import { createAiSdkTransport } from '@skaile/forge-chat/transports/ai-sdk'
const session = createChatSession({ transport: createAiSdkTransport({ endpoint: '/api/chat' }) })
await session.send({ text: 'a red circle on white', intent: 'image' })
// server/api/chat.post.ts (Nitro)
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import { createAiSdkChatHandler } from '@skaile/forge-chat/server/ai-sdk'
const provider = createOpenAICompatible({ name: 'p', baseURL: process.env.BASE_URL!, apiKey: process.env.API_KEY! })
const handler = createAiSdkChatHandler({ model: provider.chatModel('gpt-4o-mini'), imageModel: provider.imageModel('gpt-image-1') })
export default defineEventHandler(event => handler(toWebRequest(event)))
With an imageModel the handler adds a generate_image tool; intent: 'image' forces it on the first step. The image comes back as a file part (data URL) and never re-enters the model context. Invalid bodies answer 400, a provider 429 answers 429 { error: 'Rate limited' }.
Request context
model, system, tools and clientTools are each a value or a function of the validated request, resolved after the body was parsed:
interface AiSdkChatContext {
request: Request
input: ChatSendInput // the new turn
messages: ChatMessage[] // the snapshot the client sent
body: Record<string, unknown> // the whole JSON body, host fields included
}
createAiSdkChatHandler({
model: ctx => modelFor(ctx.request),
system: ctx => `Document:\n${ctx.body.document}`, // createAiSdkTransport({ body: () => ({ document }) })
tools: ctx => serverTools(ctx.request),
})
A callback that throws answers 500 with the message; an error carrying a statusCode (or status) of 400–599 (an h3 createError, say) keeps that status.
Client tools (the model calls the browser)
A tool that must run in the browser (read the open document, apply an edit) is declared per request and executed by the session:
// server: declared without `execute`; names match /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/ (the character set OpenAI and Anthropic accept, so no dots) and must not collide with `tools`
createAiSdkChatHandler({
model,
clientTools: ctx => ctx.body.clientTools as ClientToolDecls, // { [name]: { description, inputSchema: JSONSchema7 } }
})
// browser
const session = createChatSession({
transport: createAiSdkTransport({ endpoint: '/api/chat', body: () => ({ clientTools }) }),
tools: { 'doc_read': async (input, { toolCallId, signal }) => editor.getText() },
maxClientToolRounds: 4, // default; resumes per user turn
})
The loop: the model calls a client tool → the handler ends the turn with turn-end reason tool-calls → the session runs the matching handler for every running tool part of the last assistant message, in order, and reduces a tool-result (ok: true with the returned output, ok: false with the error message; a missing handler is an error result) → session.resume() posts input: { text: '', continue: true } with the snapshot that now holds the results → the server replays settled tool parts as assistant tool calls plus tool messages (errors as error-text) and the model continues. continue without a trailing tool result answers 400 nothing to continue. Past maxClientToolRounds the calls are settled as failed and a non-fatal error part ends the turn. session.send() resolves once the whole turn, resumed rounds included, ended; session.abort() ends it and drops results of handlers still running. Tool outputs must be JSON-serializable: they travel back in the next request.
Developing in this repository
The repo root is a Nuxt playground (bun run dev: /skaile replays a recorded skaile turn, /ai-sdk talks to server/api/chat.post.ts; configure it with .env.example). It loads the library from src through the forge-chat-source export condition (unpublished src; distinct from other SKAILE libraries' source conditions, so a host that enables theirs still resolves forge-chat's dist). bun run test:chat, bun run typecheck:chat, bun run build:chat.
Related Packages
Vue 3.3+ bindings for @skaile/workspaces (useAgentStore, useResourceClient, useS...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.2.2 | ^3.5.0 | MIT | |
| 0.2.1 | ^3.5.0 | MIT | |
| 0.2.0 | ^3.5.0 | MIT | |
| 0.1.0 | ^3.5.0 | MIT |
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |