HTMX AI
Stream AI responses with zero client-side JavaScript. The htmx option on the aiChat plugin adds SSE endpoints that stream HTML fragments : use hx-post to send messages and sse-connect to receive streamed responses.
#How It Works
hx-postsse-swap#Server Setup
Add htmx: true to enable SSE endpoints alongside the existing WebSocket ones. Both work simultaneously: JS frameworks use WebSocket, HTMX uses SSE.
import { aiChat } from '@absolutejs/ai';
import { anthropic } from '@absolutejs/ai/anthropic';
new Elysia().use(
aiChat({
provider: () => anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }),
systemPrompt: 'You are a helpful assistant.',
htmx: true,
})
);#HTML Form
No JavaScript needed. A standard HTML form with hx-post sends messages, and the sidebar polls with hx-trigger="every 3s".
<!-- Send a message : no JavaScript required -->
<form hx-post="/chat/message"
hx-target="#messages"
hx-swap="beforeend">
<input type="hidden" name="conversationId" value="{{convId}}" />
<textarea name="content" placeholder="Ask anything..."></textarea>
<button type="submit">Send</button>
</form>
<!-- Messages container : SSE responses append here -->
<div id="messages"></div>
<!-- Sidebar with auto-polling conversation list -->
<aside hx-get="/chat/conversations/list"
hx-trigger="load, every 3s"
hx-swap="innerHTML">
</aside>#SSE Response
The POST endpoint returns HTML with the user message and an SSE-connected container. Each sse-swap target receives a different event type: content, thinking, tools, images, and status.
<!-- The POST /chat/message endpoint returns HTML like this: -->
<!-- User message -->
<div id="msg-{id}" class="message user">
<div>What products are under $50?</div>
</div>
<!-- SSE-connected container for the AI response -->
<div id="response-{id}"
hx-ext="sse"
sse-connect="/chat/sse/{conversationId}/{messageId}"
hx-swap="innerHTML">
<div sse-swap="content" hx-swap="innerHTML"></div>
<div sse-swap="thinking" hx-swap="innerHTML"></div>
<div sse-swap="tools" hx-swap="innerHTML"></div>
<div sse-swap="images" hx-swap="innerHTML"></div>
<div sse-swap="status" hx-swap="innerHTML"></div>
</div>#Endpoints
These are added alongside the existing WebSocket and REST endpoints when htmx is enabled:
| Method | Path | Returns |
|---|---|---|
| POST | /chat/message | User message HTML + SSE container |
| GET | /chat/sse/:convId/:msgId | SSE stream of HTML fragments |
| GET | /chat/history/:convId | Full conversation as HTML |
| GET | /chat/conversations/list | Sidebar HTML fragment |
| DELETE | /chat/conversations/:id | Empty response |
#Custom Renderers
Override the default HTML output for any event type. Each render function receives the relevant data and returns an HTML string.
new Elysia().use(
aiChat({
provider: getProvider,
htmx: {
render: {
chunk: (text, fullContent) =>
`<div class="prose">${markdownToHtml(fullContent)}</div>`,
thinking: (text) =>
`<details class="think"><summary>Reasoning</summary>${text}</details>`,
toolRunning: (name) =>
`<div class="tool"><span class="spinner"></span> ${name}</div>`,
toolComplete: (name, result) =>
`<details class="tool done"><summary>${name}</summary><pre>${result}</pre></details>`,
complete: (usage, durationMs, model) =>
`<footer>${model} · ${usage?.outputTokens} tokens · ${(durationMs! / 1000).toFixed(1)}s</footer>`,
},
},
})
);