feat: chat SDK, streaming examples and a copyable integration prompt on the agent API tab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-10-09 11:22:26 +02:00
co-authored by Claude Opus 5.5
parent f103f3e9a3
commit 41b166b90b
2 changed files with 263 additions and 69 deletions
@@ -14,7 +14,15 @@
import { SlackAgentsService, type SlackChannelAgent } from '$lib/gen'
import { userStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { generateRandomString } from '$lib/utils'
import { copyToClipboard, generateRandomString } from '$lib/utils'
import { Sparkles } from 'lucide-svelte'
import {
curlExample,
fetchExample,
integrationPrompt,
runUrl,
sdkExample
} from './agentApiExamples'
/**
* The ways to use an agent outside its page: in Slack, anywhere by its `+name` and without one
@@ -127,56 +135,24 @@
let userSettings: UserSettings | undefined = $state(undefined)
let token = $state('')
let url = $derived(
`${location.origin}${base}/api/w/${workspace}/jobs/run/agent/${agentPath}` +
(chat ? '?memory_id=my-conversation-1' : '')
let target = $derived({
api: `${location.origin}${base}/api/w/${workspace}`,
agentPath,
chat,
token
})
let body = JSON.stringify({ user_message: 'Hello' }, null, 2)
let headers = $derived(
JSON.stringify(
{ 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
null,
2
)
)
let resultUrl = $derived(
`${location.origin}${base}/api/w/${workspace}/jobs_u/completed/get_result_maybe`
let apiTab = $state<string | undefined>(undefined)
let shownApiTab = $derived(
apiTab === 'sdk' && !chat ? 'rest' : (apiTab ?? (chat ? 'sdk' : 'rest'))
)
const body = JSON.stringify({ user_message: 'Hello' })
let headers = $derived({ 'Content-Type': 'application/json', Authorization: `Bearer ${token}` })
let curlCode = $derived(`TOKEN='${token}'
BODY='${body}'
URL='${url}'
UUID=$(curl -s -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" -X POST -d "$BODY" "$URL")
URL="${resultUrl}/$UUID"
while true; do
curl -s -H "Authorization: Bearer $TOKEN" "$URL" -o res.json
COMPLETED=$(cat res.json | jq .completed)
if [ "$COMPLETED" = "true" ]; then
cat res.json | jq .result.output
break
else
sleep 1
fi
done`)
let fetchCode = $derived(`export async function main() {
const UUID = await (await sendMessage()).text();
return await waitForAnswer(UUID);
}
async function sendMessage() {
return await fetch(\`${url}\`, {
method: 'POST',
headers: ${JSON.stringify(headers, null, 2).replaceAll('\n', '\n ')},
body: JSON.stringify(${body})
});
}
async function waitForAnswer(UUID) {
while (true) {
const res = await fetch(\`${resultUrl}/\${UUID}\`, {
headers: { Authorization: 'Bearer ${token}' }
});
const data = await res.json();
if (data.completed) return data.result?.output;
await new Promise((r) => setTimeout(r, 1000));
}
}`)
</script>
<UserSettings
@@ -224,7 +200,8 @@ async function waitForAnswer(UUID) {
{/if}
{#if !botName}
<Alert type="warning" title="Windmill can't reach Slack" size="xs">
Slack refused the workspace's bot token or couldn't be reached, so agents can't answer there.
Slack refused the workspace's bot token or couldn't be reached, so agents can't
answer there.
{#if isAdmin}
<a href="{base}/workspace_settings?tab=slack">Reconnect Slack</a> to fix it.
{:else}
@@ -310,11 +287,28 @@ async function waitForAnswer(UUID) {
</TabContent>
<TabContent value="api" class="flex flex-col gap-6 p-4">
<span class="text-xs text-secondary">
Each call runs the agent as the token's owner and returns the id of the run.{#if chat}{' '}Calls
that share a <code>memory_id</code> are one conversation: reuse it for follow-ups and pick
a new one to start over.{/if}
</span>
<div class="flex flex-col gap-2">
<span class="text-xs text-secondary">
Each call runs the agent as the token's owner and returns the id of the run, whose
answer streams as it is written.{#if chat}{' '}Calls that share a
<code>memory_id</code>, any string you choose, are one conversation.{/if}
</span>
<div>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Sparkles }}
onClick={() => copyToClipboard(integrationPrompt(target))}
>
Copy a prompt for your AI assistant
<Tooltip light>
Everything a coding assistant needs to integrate this agent: the endpoints,
streaming{chat ? ', conversations and the chat library' : ''}. The token is left
as a placeholder.
</Tooltip>
</Button>
</div>
</div>
<Label label="Token">
<div class="flex flex-col gap-2">
<TextInput
@@ -337,30 +331,39 @@ async function waitForAnswer(UUID) {
</div>
</div>
</Label>
<Tabs selected="rest">
<Tabs bind:selected={() => shownApiTab, (t) => (apiTab = t)}>
{#if chat}
<Tab value="sdk" label="Chat SDK" />
{/if}
<Tab value="rest" label="REST" />
<Tab value="curl" label="Curl" />
<Tab value="fetch" label="Fetch" />
{#snippet content()}
<TabContent value="rest" class="flex flex-col gap-6 mt-2">
<Label label="Url"><ClipboardPanel content={url} /></Label>
<Label label="Body">
<ClipboardPanel content={JSON.stringify(JSON.parse(body), null, 2)} />
</Label>
<Label label="Headers">
<ClipboardPanel content={JSON.stringify(headers, null, 2)} />
</Label>
<TabContent value="sdk" class="flex flex-col gap-2 mt-2">
<span class="text-xs text-secondary">
Then poll <code class="break-all">{resultUrl}/&lbrace;id&rbrace;</code> until
<code>completed</code>
is true; the answer is the result's <code>output</code>.
React with <code>windmill-chat</code>: streaming, tool calls and conversations
handled. It also plugs into the Vercel AI SDK and assistant-ui.
</span>
<CopyableCodeBlock code={sdkExample(target)} language={typescript} />
</TabContent>
<TabContent value="rest" class="flex flex-col gap-6 mt-2">
<Label label="Url">
<ClipboardPanel
content={runUrl(target) + (chat ? '?memory_id=<conversation id>' : '')}
/>
</Label>
<Label label="Body"><ClipboardPanel content={body} /></Label>
<Label label="Headers"><ClipboardPanel content={headers} /></Label>
<span class="text-xs text-secondary">
Then follow the run: the Fetch example streams it, the Curl one waits for the
answer.
</span>
</TabContent>
<TabContent value="curl" class="mt-2">
<CopyableCodeBlock code={curlCode} language={bash} wrap />
<CopyableCodeBlock code={curlExample(target)} language={bash} />
</TabContent>
<TabContent value="fetch" class="mt-2">
<CopyableCodeBlock code={fetchCode} language={typescript} wrap />
<CopyableCodeBlock code={fetchExample(target)} language={typescript} />
</TabContent>
{/snippet}
</Tabs>
@@ -0,0 +1,191 @@
/**
* The code an agent's API tab offers to copy. Every example is meant to run as copied once the
* token is filled in, so each one is exercised against a live agent before it changes here.
*/
export type AgentApiTarget = {
/** `https://host/api/w/<workspace>`, base path included. */
api: string
agentPath: string
/** Whether the agent keeps the conversation: only then is `memory_id` worth sending. */
chat: boolean
token: string
}
export function runUrl({ api, agentPath }: AgentApiTarget): string {
return `${api}/jobs/run/agent/${agentPath}`
}
export function curlExample(t: AgentApiTarget): string {
const conversation = t.chat
? `# Any string names a conversation: reuse it for follow-ups, pick a new one to start over.
CONVERSATION=$(uuidgen)
`
: ''
return `TOKEN='${t.token}'
${conversation}JOB=$(curl -s -X POST "${runUrl(t)}${t.chat ? '?memory_id=$CONVERSATION' : ''}" \\
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \\
-d '{"user_message": "Hello"}')
while true; do
RES=$(curl -s "${t.api}/jobs_u/completed/get_result_maybe/$JOB" -H "Authorization: Bearer $TOKEN")
if [ "$(echo "$RES" | jq .completed)" = "true" ]; then
echo "$RES" | jq -r '.result.output // .result.error.message'
break
fi
sleep 1
done`
}
export function fetchExample(t: AgentApiTarget): string {
const usage = t.chat
? `export async function main() {
// Any string names a conversation: reuse it for follow-ups, pick a new one to start over.
const conversation = crypto.randomUUID();
await ask('Hello', conversation, (text) => console.log(text));
return await ask('What did I just say?', conversation, (text) => console.log(text));
}`
: `export async function main() {
return await ask('Hello', (text) => console.log(text));
}`
return `const API = '${t.api}';
const HEADERS = { Authorization: 'Bearer ${t.token}' };
${usage}
/** Runs the agent and streams its answer to \`onText\` as it is written; resolves to the full answer. */
async function ask(message${t.chat ? ', conversation' : ''}, onText) {
const run = await fetch(\`\${API}/jobs/run/agent/${t.agentPath}${t.chat ? '?memory_id=${conversation}' : ''}\`, {
method: 'POST',
headers: { ...HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ user_message: message })
});
if (!run.ok) throw new Error(await run.text());
const job = (await run.text()).trim();
// The server closes the stream now and then: reconnect from the last offset, never rerun.
let offset = 0;
let pending = '';
for (;;) {
const res = await fetch(
\`\${API}/jobs_u/getupdate_sse/\${job}?fast=true&only_result=true&stream_offset=\${offset}\`,
{ headers: HEADERS }
);
let buffer = '';
for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
buffer += chunk;
let end;
while ((end = buffer.indexOf('\\n\\n')) >= 0) {
const frame = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
if (!frame.startsWith('data:')) continue;
const update = JSON.parse(frame.slice(5));
if (update.new_result_stream) {
// One JSON event per line; a chunk can stop mid-line.
pending += update.new_result_stream;
offset = update.stream_offset;
const lines = pending.split('\\n');
pending = lines.pop();
for (const line of lines) {
const event = JSON.parse(line);
if (event.type === 'token_delta') onText(event.content);
// Also: reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result
}
}
if (update.completed) {
const result = update.only_result;
if (result?.error) throw new Error(result.error.message);
return result?.output;
}
}
}
}
}`
}
export function sdkExample(t: AgentApiTarget): string {
return `import { useWindmillChat } from 'windmill-chat/react'
const API = '${t.api}'
const TOKEN = '${t.token}'
export function AgentChat() {
const chat = useWindmillChat({
baseUrl: '${new URL(t.api).origin}',
workspace: '${t.api.split('/').pop()}',
token: TOKEN,
// What the agent's conversations are filed under. With a token, history stays in this
// browser; history: 'server' also needs the token's flow_conversations:write scope.
flowPath: '${t.agentPath}.chat',
// Runs the agent for each turn; the conversation id is its memory.
run: async (args, { conversationId, signal }) => {
const res = await fetch(\`\${API}/jobs/run/agent/${t.agentPath}?memory_id=\${conversationId}\`, {
method: 'POST',
headers: { Authorization: \`Bearer \${TOKEN}\`, 'Content-Type': 'application/json' },
body: JSON.stringify(args),
signal
})
if (!res.ok) throw new Error(await res.text())
return (await res.text()).trim()
}
})
// chat.messages (streamed as they are written), chat.status, chat.sendMessage(text), chat.stop(),
// and the conversations: chat.conversations, chat.selectConversation(id), chat.newConversation()
}`
}
/** A brief for a coding assistant asked to integrate the agent. The token stays a placeholder. */
export function integrationPrompt(t: AgentApiTarget): string {
const conversation = t.chat
? `
## Conversations
The agent keeps the conversation. Pass \`memory_id\` as a query parameter on every run: any
string names a conversation, a UUID is best. Reuse it for follow-ups; a new one starts over.
Without it each message is answered on its own.
Windmill also keeps each conversation, as the token's owner's. One token shared by all your
users makes every conversation theirs, so keep your users' conversation ids in your own app.
Reading them back needs a token with the \`flow_conversations:read\` scope (\`:write\` to delete):
- \`GET ${t.api}/flow_conversations/list?flow_path=${t.agentPath}.chat\` (\`page\`, \`per_page\`).
A conversation's \`id\` is the \`memory_id\` when that was a UUID.
- \`GET ${t.api}/flow_conversations/{id}/messages\`: its latest messages, oldest first.
- \`DELETE ${t.api}/flow_conversations/delete/{id}\`.
For a chat UI in React, the \`windmill-chat\` library (https://github.com/windmill-labs/windmill/tree/main/chat-sdk)
does all of this: \`useWindmillChat\` from \`windmill-chat/react\`, or the Vercel AI SDK transport
(\`windmill-chat/ai-sdk\`) and assistant-ui runtime (\`windmill-chat/assistant-ui\`). Point it at the
agent with \`flowPath: '${t.agentPath}.chat'\` and a \`run(args, { conversationId, signal })\` option
that POSTs \`args\` to the run endpoint with \`memory_id=conversationId\` and returns the job id.
`
: ''
return `Integrate the Windmill AI agent \`${t.agentPath}\` into this project.
## Authentication
Every request sends \`Authorization: Bearer <WINDMILL_TOKEN>\`. Read the token from configuration,
never from code shipped to a browser: the agent runs as the token's owner, with their permissions.
## Sending a message
\`POST ${runUrl(t)}\` with the JSON body \`{"user_message": "..."}\` starts a run and returns its
job id as plain text. An error is a non-2xx status with the reason as text.
${conversation}
## Streaming the answer
\`GET ${t.api}/jobs_u/getupdate_sse/{job_id}?fast=true&only_result=true\` is a server-sent events
stream of JSON objects:
- \`new_result_stream\`: text to append to a buffer holding one JSON event per line. A chunk can
stop mid-line, so parse complete lines only. Event types: \`token_delta\` (\`content\`: answer text),
\`reasoning_token_delta\`, \`tool_call\`, \`tool_call_arguments\`, \`tool_execution\`, \`tool_result\`
(\`call_id\`, \`function_name\`, \`result\`, \`success\`).
- \`stream_offset\`: how far the stream has been read. The server closes the stream now and then;
reconnect with \`&stream_offset=<last value>\` and never run the agent again for the same message.
- \`completed: true\` ends it. \`only_result.output\` is the full answer, or \`only_result.error.message\`
when the run failed.
Without streaming, poll \`GET ${t.api}/jobs_u/completed/get_result_maybe/{job_id}\` until
\`completed\` is true; the answer is \`result.output\`.
To stop a run: \`POST ${t.api}/jobs_u/queue/cancel/{job_id}\` with \`{"reason": "..."}\`.
`
}