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

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