Starter project

A two-endpoint Joule Cloud app you can clone, deploy, and use as a template. Built in TypeScript with the OpenAI SDK and a Postgres-shaped DB.

What it does

One service, two routes:

Get the code

git clone https://git.openie.sh/openie/joule-starter.git
cd joule-starter
cp .env.example .env
# edit .env with your JC_API_KEY
docker build -t ghcr.io/me/joule-starter:1.0 .
docker push ghcr.io/me/joule-starter:1.0
jc db create joule-starter --region eu-fi --size 10GB
invisible deploy

What's in the repo

joule-starter/
├── Dockerfile           # multi-stage Node 22 build, < 50 MB image
├── invisible.hcl        # workload + db + route declaration
├── .env.example         # JC_API_KEY, DATABASE_URL
├── .github/workflows/
│   └── deploy.yml       # CI: build → push → invisible deploy
├── src/
│   ├── server.ts        # Fastify app, two routes
│   ├── db.ts            # sqlx-like Postgres client
│   └── joule-client.ts  # OpenAI SDK wrapper with header capture
├── migrations/
│   └── 001_init.sql     # questions table
└── package.json

The handler shape

// src/server.ts
import Fastify from "fastify";
import { askJoule } from "./joule-client.js";
import { saveAnswer, recentAnswers } from "./db.js";

const app = Fastify({ logger: true });

app.post("/ask", async (req) => {
  const { question } = req.body as { question: string };
  const { answer, joules, tier } = await askJoule(question);
  await saveAnswer({ question, answer, joules, tier });
  return { answer, joules, tier };
});

app.get("/history", async () => {
  return await recentAnswers(50);
});

app.listen({ port: 3000, host: "0.0.0.0" });

The joule-aware client

// src/joule-client.ts
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://api.greenjoules.cloud/v1",
  apiKey: process.env.JC_API_KEY!,
});

export async function askJoule(question: string) {
  const resp = await client.chat.completions
    .with_raw_response
    .create({
      model: "auto",
      messages: [
        { role: "system", content: "You are a concise assistant." },
        { role: "user",   content: question },
      ],
    });
  const data = resp.parse();
  return {
    answer: data.choices[0].message.content ?? "",
    joules: parseFloat(resp.headers.get("x-energy-joules") ?? "0"),
    tier:   resp.headers.get("x-tier") ?? "",
  };
}

The schema

-- migrations/001_init.sql
CREATE TABLE IF NOT EXISTS questions (
  id BIGSERIAL PRIMARY KEY,
  question TEXT NOT NULL,
  answer TEXT NOT NULL,
  joules NUMERIC(10, 4) NOT NULL,
  tier TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX questions_created_idx ON questions (created_at DESC);

The deploy file

# invisible.hcl
workload "joule-starter" {
  image  = "ghcr.io/me/joule-starter:1.0"
  region = "eu-fi"
  cpu    = "1 vCPU"
  memory = "512 MB"
  scale = { min = 0, max = 10 }
  env = {
    JC_API_KEY    = "secret:JC_API_KEY"
    DATABASE_URL  = database.joule-starter.url
    NODE_ENV      = "production"
  }
}

database "joule-starter" {
  region  = "eu-fi"
  size_gb = 10
}

route "starter.example.com" {
  to    = workload.joule-starter
  https = true
}

The CI step

# .github/workflows/deploy.yml
name: deploy
on: { push: { branches: [main] } }
jobs:
  ship:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
      - run: docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
      - run: |
          sed -i "s|image *= .*|image = \"ghcr.io/${{ github.repository }}:${{ github.sha }}\"|" invisible.hcl
          curl -L https://invisible.openie.sh/install.sh | sh
          jc login --token "${{ secrets.JC_API_KEY }}"
          invisible deploy --auto-approve

What to do with it

Source & license

MIT. PRs welcome. Repo at git.openie.sh/openie/joule-starter (mirror on GitHub at openIE-dev/joule-starter).

What to read next

If you want to add observability to the starter, jump to Energy receipts. If you want to push this shape to a fuller RAG app, jump to Build a RAG app. If you want to add Functions for cron + webhook handlers, jump to Functions.