Tutorial: Build a chatbot
Forty-five minutes from jc login to a deployed chatbot. We use Next.js, the Vercel AI SDK, and Joule Cloud Functions for the backend. The chatbot streams responses, shows per-message energy used, and works in development and in production with two-line config swaps.
What you'll build
- A Next.js 15 app with the
useChathook from the Vercel AI SDK - A streaming chat endpoint pointed at Joule Cloud Inference
- The
x-energy-joulesresponse header displayed under each message - Deployed as a Joule Cloud Compute workload at
chat.example.com
Step 1 — scaffold
npx create-next-app@latest joulebot --typescript --tailwind --app
cd joulebot
npm install ai @ai-sdk/openai zod
Step 2 — server: streaming chat handler
// app/api/chat/route.ts
import { createOpenAI } from "@ai-sdk/openai";
import { streamText, type Message } from "ai";
const jc = createOpenAI({
baseURL: "https://api.greenjoules.cloud/v1",
apiKey: process.env.JC_API_KEY,
});
export async function POST(req: Request) {
const { messages }: { messages: Message[] } = await req.json();
const result = await streamText({
model: jc("auto"),
system: "You are a concise, friendly assistant.",
messages,
});
// Forward the joule header back to the client
const headers = new Headers();
const joules = (result.providerMetadata as any)?.openai?.headers?.["x-energy-joules"];
if (joules) headers.set("x-energy-joules", joules);
return result.toDataStreamResponse({ headers });
}
Step 3 — client: the chat UI
// app/page.tsx
"use client";
import { useChat } from "ai/react";
export default function Page() {
const { messages, input, handleInputChange, handleSubmit, data } = useChat();
return (
<main className="max-w-2xl mx-auto p-6">
{messages.map((m, i) => (
<div key={m.id} className="my-4">
<div className="font-semibold">{m.role}</div>
<div className="whitespace-pre-wrap">{m.content}</div>
{m.role === "assistant" && (
<div className="text-sm text-gray-500">
{/* the AI SDK exposes streamed metadata under "data" */}
</div>
)}
</div>
))}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
className="flex-1 border rounded p-2"
placeholder="Ask anything"
/>
<button className="bg-black text-white rounded px-4">Send</button>
</form>
</main>
);
}
Step 4 — run it locally
export JC_API_KEY=jc_… # from portal.greenjoules.cloud
npm run dev
Step 5 — Dockerfile
# Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
Step 6 — invisible.hcl
workload "joulebot" {
image = "ghcr.io/me/joulebot:1.0"
region = "auto"
cpu = "1 vCPU"
memory = "1 GB"
env = {
JC_API_KEY = "secret:JC_API_KEY" # injected from the portal secret store
}
scale = { min = 0, max = 50 }
}
route "chat.example.com" {
to = workload.joulebot
https = true
}
Step 7 — deploy
docker build -t ghcr.io/me/joulebot:1.0 .
docker push ghcr.io/me/joulebot:1.0
invisible deploy
That's the whole thing. The chatbot is live at https://chat.example.com, scaling from zero to fifty, billed in joules per request, with a per-message energy footer.
Next steps
- Add memory: persist conversations to JouleDB.
- Add retrieval: see Build a RAG app.
- Add observability: pull
X-Energy-Joulesfrom every response into your analytics, sort prompts by energy cost, find the prompts that are accidentally expensive.