AbsoluteJS

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

Form POST
User submits via hx-post
HTML + SSE
Server returns SSE-connected container
HTML Fragments
Chunks stream as rendered HTML via sse-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.

TS
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".

HTML
<!-- 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.

HTML
<!-- 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:

MethodPathReturns
POST/chat/messageUser message HTML + SSE container
GET/chat/sse/:convId/:msgIdSSE stream of HTML fragments
GET/chat/history/:convIdFull conversation as HTML
GET/chat/conversations/listSidebar HTML fragment
DELETE/chat/conversations/:idEmpty response

#Custom Renderers

Override the default HTML output for any event type. Each render function receives the relevant data and returns an HTML string.

TS
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>`,
      },
    },
  })
);