Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018aQiZNAU8g17kWkyTryS5J
windmill-chat
Build a chat interface on a Windmill flow deployed in chat mode, from any frontend or from a Windmill raw app. The library is headless: it runs the flow, follows the answer as it streams, keeps the conversation history, and hands you state to render.
npm install windmill-chat
No runtime dependencies. react is an optional peer dependency for windmill-chat/react.
The flow
Any deployed flow with Chat mode enabled in its settings works. Windmill passes the
message as the user_message input and threads the conversation through memory_id,
so an AI agent step remembers earlier turns. The answer is:
- what the last step streams, when it is an AI agent step;
- otherwise the flow's result: its
windmill_chat_answerfield when it has one, a string as is, anything else as JSON.
React
import { useWindmillChat } from 'windmill-chat/react'
export function Support() {
const chat = useWindmillChat({
baseUrl: 'https://app.windmill.dev',
workspace: 'acme',
flowPath: 'f/support/assistant',
token: () => fetch('/api/windmill-token').then((r) => r.text())
})
const [draft, setDraft] = useState('')
return (
<div>
{chat.messages.map((m) => (
<p key={m.id} data-role={m.role} data-pending={m.pending}>
{m.content}
</p>
))}
<form
onSubmit={(e) => {
e.preventDefault()
chat.sendMessage(draft)
setDraft('')
}}
>
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
<button disabled={chat.status !== 'idle' && chat.status !== 'error'}>Send</button>
{chat.status === 'streaming' && <button onClick={chat.stop}>Stop</button>}
</form>
</div>
)
}
The hook returns the state plus the chat's methods, and recreates the chat
when flowPath, baseUrl, workspace or history change.
Raw apps
Inside a Windmill raw app nothing needs configuring: the chat runs as the viewer, against the Windmill the app is served from.
const chat = useWindmillChat({ flowPath: 'f/support/assistant' })
- Unsandboxed app (the default): the viewer's session is used. Viewers need permission to run the flow.
- Sandboxed app: declare
jobs:runin the app's frontend SDK scopes, andflow_conversations:writefor server-side history. The viewer consents once and the app receives a token restricted to those scopes. wmill app dev: there is no viewer session on the dev server, so passbaseUrl,workspaceandtokenexplicitly during development.
Any framework
createChat returns a store: subscribe calls the listener immediately and on every
change, and returns the unsubscribe function. That is the Svelte store contract, so
$chat works as is; other frameworks wrap it in a few lines.
import { createChat } from 'windmill-chat'
const chat = createChat({ baseUrl, workspace, flowPath, token })
chat.subscribe((state) => render(state))
await chat.sendMessage('Hello')
<script>
import { createChat } from 'windmill-chat'
const chat = createChat({ flowPath: 'f/support/assistant' })
</script>
{#each $chat.messages as m (m.id)}
<p>{m.content}</p>
{/each}
<button onclick={() => chat.sendMessage(draft)}>Send</button>
Options
| Option | |
|---|---|
flowPath |
Path of the deployed flow, e.g. f/support/assistant. Required. |
baseUrl |
The Windmill origin. Detected inside a raw app. |
workspace |
Detected inside a raw app. |
token |
A token, or a function returning one (called before every request, so it can fetch a short-lived token from your backend). Omit it inside a raw app. |
history |
'server', 'local' or 'none', see History. Defaults to 'server' with a viewer session and 'local' with an explicit token. |
inputs |
Extra flow inputs sent with every message. sendMessage(text, { inputs }) adds per-message ones. |
fetch, storage |
Replacements for the globals, for tests and unusual runtimes. |
pageSize |
Messages and conversations per page of server history. Default 50. |
State
interface ChatState {
conversationId: string | undefined
messages: ChatMessage[]
status: 'idle' | 'submitted' | 'streaming' | 'error'
error: Error | undefined
conversations: Conversation[]
history: 'server' | 'local' | 'none'
loadingMessages: boolean
hasMoreMessages: boolean
}
interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'tool' | 'system'
content: string
reasoning?: string // the model's reasoning summary, when streamed
tool?: { callId?: string; name: string; arguments?: string; result?: string; status: 'running' | 'success' | 'error' }
success: boolean // false for a failed flow or tool
pending: boolean // still streaming, or not yet confirmed by the server
createdAt: string
jobId?: string
stepName?: string
}
A turn goes submitted (the flow is queued) → streaming (the answer is arriving) →
idle. Tool calls appear as tool messages whose status moves from running to
success or error. A flow that fails still completes the turn: its error is the
answer, an assistant message with success: false. status: 'error' (with error
set) means the turn could not run or be followed at all, such as a refused request.
Methods: sendMessage(text, { inputs? }), stop(), newConversation(),
selectConversation(id), loadConversations({ page?, perPage? }),
deleteConversation(id), loadOlderMessages(), destroy(). Switching conversations
stops following the current answer; the flow keeps running and, with server history,
its answer is there when you come back.
History
Windmill stores every conversation of a chat-mode flow, and each Windmill user sees
only their own. history: 'server' reads that store: loadConversations() lists
them, selectConversation(id) loads one, loadOlderMessages() pages back. Every
message rendered from the server carries its jobId and stepName.
That store is keyed by the Windmill user, so it fits a viewer session or a token
issued per user. With one token shared by every visitor of a site, all visitors would
see each other's conversations. For that setup use history: 'local' (the default
with an explicit token): the conversation list and messages stay in the browser's
localStorage, per Windmill instance, workspace and flow. 'none' keeps nothing
beyond the page.
When the default 'server' mode turns out unreadable (a token or sandboxed app
without flow_conversations scopes), the chat switches itself to 'local' and
state.history says so. Passing history explicitly disables that fallback.
Tokens
Anything a browser holds can be read by its user, so give a chat token exactly what the chat needs:
| Setup | Scopes |
|---|---|
| Public site, one token for everyone | jobs:run:flows:f/support/assistant, and history: 'local'. The token can run that one flow and follow its jobs, nothing else. |
| Per-user tokens minted by your backend | The above plus flow_conversations:write for server-side history. Return them from an endpoint and pass token: () => fetch(...). |
| A Windmill user in the browser (raw app, embedded Windmill) | No token: the session is used. |
The token's user must be allowed to run the flow. stop() closes the stream in any
case; cancelling the run on the server as well needs jobs:write, which also lets the
token read every job its user can see, so leave it out unless that matters.
Anyone holding the token can run the flow with inputs of their choosing, so a flow
exposed this way should treat user_message and the other inputs as untrusted.
Lower level
WindmillChatApi wraps the endpoints (runFlow, streamJob, listConversations,
listMessages, deleteConversation, cancelJob), parseStreamEvents and
createStreamEventParser decode the AI agent stream, and extractChatAnswer turns a
flow result into the text a chat shows. They are exported for custom state management.