* feat: run turns in several flow chat conversations at once Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep finished turns finished and cached chats current in the flow chat pool Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: attribute a turn's rows by job id as well as sequence Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: count only real stream updates and retry the job-id read Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a chat that holds an unsent draft, and take one back when its first turn is withdrawn Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: ignore a stale running-turn snapshot, keep a withdrawn chat's draft, poll after clean stream ends Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: follow the turn running now when the listing named one already over Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep replacement turns and SSE fallback moving * fix: keep replacement turn handoffs active * fix: preserve unread badge line height * fix: settle local fallback handoffs * fix: settle refused turn handoffs * fix: scope turn handoffs to conversation * fix: drop stale turn handoffs * refactor: move the queued message and 409 handling into per-conversation turns Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address cubic's review of the parallel flow chat turns Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: clear a stale failure on refresh, and tighten the docs and test waits Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: recover running rows past the first page, and drop the failure a re-read disproves Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: check the running-turn query at compile time, and narrow what a refresh clears Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: settle a failed turn only from an answer that turn wrote Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: settle a failed turn from its own answer, and only while it is still the failure shown Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop a failure whose answer arrived even when a newer turn owns the error Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: free an answered failure whatever the turn that started meanwhile is doing Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop a rows read that a turn outran, rather than merging it under newer messages Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop a rows read whose conversation was left and opened again Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: hand over a file still being read when its composer goes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: count a drop's routing as work in flight, so its file is handed over too Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: hold the send until every file a conversation is owed has landed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: keep a panel mounted per conversation instead of handing its draft over Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the withdrawn chat whose composer was written in, not the empty one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the chat in front of the reader when both withdrawn composers were written in Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a retry's own run arguments when a turn elsewhere refuses it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: name panels apart across pools, and read a flow's inputs when its chat is built Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
16 KiB
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. Optional peers: react for windmill-chat/react, ai for
windmill-chat/ai-sdk, @assistant-ui/react for windmill-chat/assistant-ui.
| You build the UI with | Import | You get |
|---|---|---|
Vercel AI SDK useChat, AI Elements |
windmill-chat/ai-sdk |
a ChatTransport: useChat({ transport }), nothing else changes |
| assistant-ui | windmill-chat/assistant-ui |
a runtime for AssistantRuntimeProvider, threads included |
| Your own components | windmill-chat/react or windmill-chat |
a hook / a store with messages, status and actions |
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.
Vercel AI SDK (useChat, AI Elements)
import { useChat } from '@ai-sdk/react'
import { createWindmillChatTransport } from 'windmill-chat/ai-sdk'
const transport = createWindmillChatTransport({
baseUrl: 'https://app.windmill.dev',
workspace: 'acme',
flowPath: 'f/support/assistant',
token: () => fetch('/api/windmill-token').then((r) => r.text())
})
export function Support() {
const { messages, status, sendMessage, stop } = useChat({ id: conversationId, transport })
// render `messages[i].parts`: text, reasoning and dynamic-tool parts, as with any AI SDK backend
}
The chat id is the conversation: reuse it to continue one, and pass a UUID when you
also read server history, so it matches what flow_conversations stores (any other id
maps to a fixed UUID). sendMessage(msg, { body }) sends extra flow inputs. Tool calls
arrive as dynamic-tool parts (input-available → output-available | output-error),
which AI Elements' <Tool> renders as is. A failed flow surfaces as error.
regenerate() runs the flow again with the same message: a new turn on the server,
not a replacement of the previous answer.
The transport also carries the history helpers: transport.loadMessages(id) returns
UIMessages for useChat({ messages }) or setMessages, transport.listConversations()
and transport.deleteConversation(id). A loaded user message lists the files it carried
in metadata.attachments; WindmillChatApi.attachmentUrl gives each one's download URL.
Sending attachments is not supported: sendMessage with files is refused with an
explanatory error.
assistant-ui
import { AssistantRuntimeProvider } from '@assistant-ui/react'
import { useWindmillRuntime } from 'windmill-chat/assistant-ui'
export function Support() {
const runtime = useWindmillRuntime({ baseUrl, workspace, flowPath, token })
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread /> {/* your assistant-ui components, thread list included */}
</AssistantRuntimeProvider>
)
}
Conversations are threads: ThreadListPrimitive switches, creates and deletes them.
Tool calls render through your tools components (MessagePrimitive.Parts), reasoning
through Reasoning. It takes the same options as useWindmillChat below.
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 type="button" onClick={chat.stop}>
Stop
</button>
)}
</form>
</div>
)
}
The hook returns the state plus the chat's methods. It recreates the chat
(fresh state, old one destroyed) when flowPath, baseUrl, workspace, history,
storageKey or the credential change: a different token string, or a switch between
no token, a string and a function; and when a run callback appears or goes away.
A token function is called through a ref, so passing a new closure on every render
is fine and never resets the chat, and so are run and the callbacks; when users
sign in and out behind a token function, change storageKey (their id) so local
history and state start over with them.
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, and { attachments, attachmentsInput } files, see Attachments. |
storageKey |
Namespace for local history, e.g. the signed-in user's id. Local history is per browser and per flow; without it, users sharing a browser share it. |
fetch, storage |
Replacements for the globals, for tests and unusual runtimes. |
pageSize |
Messages and conversations per page of server history. Default 50. |
pollDelayMs |
How often, in ms, the server polls a running turn for the stream (Enterprise; 50 at the fastest, other servers ignore it). Unset, the server relaxes from 100 ms to 3 s over a long turn; set it when tokens must keep flowing at that pace. |
onFinish, onError |
Called when a turn has its answer, or could not run at all. |
run |
Runs the flow for a turn yourself and returns the job id, instead of the deployed flow at flowPath (Windmill's editor chats with an undeployed flow through a preview run this way). Pass memory_id = the conversation id. |
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
serverId?: string // the persisted row; `id` itself never changes, so list keys are stable
}
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?, attachments?, attachmentsInput? }), stop(),
newConversation(), selectConversation(id), loadConversations({ page?, perPage?, kind? }),
deleteConversation(id), renameConversation(id, title), loadOlderMessages(),
resumeTurn(turn), refreshMessages() (reads what another tab added to the open
conversation), destroy(). kind lists the flow editor's test chats ('test'), the deployed flow's
own ('deployed', the server's default) or both ('all'); each Conversation carries
isTest. A rename keeps the conversation's place in the list. Switching conversations
stops following the current answer; the flow keeps running and, with server history,
its answer is there when you come back.
Attachments
A flow whose AI agent step reads user_attachments from an s3object[] (or a single
s3object) flow input takes files with a message:
await chat.sendMessage('What does this contract say?', {
attachments: [{ name: file.name, data: file }], // a Blob/File, or a `data:` URL
attachmentsInput: { name: 'files', multiple: true }
})
Each file is uploaded to the workspace's object storage under
windmill_uploads/chat/<turn>/<index>/<name> and handed to the input as { s3, filename }
objects (the object for a single-file input). Once the uploads return, the pending user
message lists them in attachments, as { input, s3, filename } references. The name's
extension is corrected to the file's media type for PNG, JPEG and PDF, because the worker
reads the type off the key.
Files need message text to go with them. A failed upload rejects sendMessage before any
run starts, and stop() during the upload aborts it; both leave the transcript as it was.
The chat never deletes uploads, so files of a send that did not run stay in storage. The
workspace needs object storage set up. With Enterprise advanced storage permissions, the
user needs read and write on windmill_uploads/*, which the default rules grant. The upload goes through
job_helpers, so a restricted token needs job_helpers:write; a sandboxed raw app cannot
request that scope today, so attachments are not available there yet.
One turn at a time
A conversation answers one message at a time, and Windmill enforces it: a message sent
while its previous turn still runs (from another tab, or before a reload) is refused.
sendMessage then rejects with a TurnRunningError and shows nothing of the message.
With server history, a listed Conversation also carries runningTurn while it is
answering. Either way, resumeTurn(turn) follows that turn in the selected
conversation: its answer streams in from the start and the turn finishes as if it had
been sent here, after which the message can be sent again.
// `runningTurn` comes from the listing, so read it before selecting.
await chat.loadConversations()
const running = chat.getState().conversations.find((c) => c.id === id)?.runningTurn
await chat.selectConversation(id)
if (running) await chat.resumeTurn(running)
A dropped connection to the answer is retried; when it keeps failing, the chat stops streaming and waits for the flow's result instead, so the turn still ends.
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, with history: 'server' (an explicit token defaults to local 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), followJob follows a run to
completion across the server's stream timeouts, parseStreamEvents decodes the AI
agent stream, extractChatAnswer turns a flow result into the text a chat shows, and
conversationIdFor maps any chat id to its conversation UUID. They are exported for
custom integrations.
For AI coding agents
When asked to add a chat over a Windmill flow: the flow must be deployed with chat mode
on. Pick the entry point from the table at the top (useChat → windmill-chat/ai-sdk,
assistant-ui → windmill-chat/assistant-ui, otherwise windmill-chat/react). Inside a
Windmill raw app pass only flowPath. Elsewhere pass baseUrl, workspace and a
token; for a public page use a token scoped to jobs:run:flows:<flowPath> and leave
history at its default. Render role, content, pending, success and
tool.status; never build the SSE handling yourself.