Files
windmill/chat-sdk/README.md
T
GuilhemandClaude Opus 5 c4c9677982 feat: attach files to a flow chat message (#11185)
* feat: attach files to a flow chat message

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: store chat uploads under windmill_uploads and withdraw refused sends cleanly

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse extra files for a single-file input and keep attachments on retry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: clean up partial upload batches, withdraw a stopped send once, free retry payloads

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: list a chat conversation only once its run starts and require files where the flow does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: carry uploaded attachments on the pending chat user message

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: route rich-composer file paste through the attachment lanes and tighten comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep a stopped turn's files for retry and let an explicit media type win

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse chat attachments sent without text and shorten comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve the attachments input from real flow input reads, ignoring loop iteration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: read flow input references through one parser for the model and attachments controls

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: withdraw an attachment send stopped after its uploads answered

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: discard uploads when a send is stopped as its attachments are announced

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: keep uploadAttachments' doc comment on uploadAttachments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: never delete chat uploads from workspace storage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: pin that a failed upload aborts the rest of its batch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 10:16:10 +02:00

318 lines
14 KiB
Markdown

# 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_answer` field when it has one, a
string as is, anything else as JSON.
## Vercel AI SDK (`useChat`, AI Elements)
```tsx
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
`UIMessage`s 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
```tsx
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
```tsx
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](#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.
```tsx
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:run` in the app's frontend SDK scopes, and
`flow_conversations:write` for 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 pass
`baseUrl`, `workspace` and `token` explicitly 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.
```ts
import { createChat } from 'windmill-chat'
const chat = createChat({ baseUrl, workspace, flowPath, token })
chat.subscribe((state) => render(state))
await chat.sendMessage('Hello')
```
```svelte
<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](#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](#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
```ts
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()`,
`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:
```ts
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.
## 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.