Starter kits

Copy a working project and change it

Each kit is one or two files with no framework to learn. Add your key, run it, then make it yours.

Python · Chat completions, Auto, fallback

Chatbot with fallback

A terminal chatbot that streams answers, lets Sparrow Auto pick the model, falls back to another model when a provider fails, and shows what each reply cost.

Get the files

mkdir chatbot && cd chatbot
curl -fsSL --create-dirs -o chat.py https://sparrowcodes.com/starters/chatbot/chat.py

Run it

pip install openai
export SPARROW_API_KEY="sk-sc-v1-..."
python chat.py
Read the README →
"""A terminal chatbot on SparrowCodes: streaming answers, automatic fallback to other models, and the cost of every reply.

    pip install openai
    export SPARROW_API_KEY="sk-sc-v1-..."
    python chat.py
"""
import os

from openai import OpenAI

client = OpenAI(
    base_url=os.environ.get("SPARROW_BASE_URL", "https://api.sparrowcodes.com/v1"),
    api_key=os.environ["SPARROW_API_KEY"],
    # Optional: name your app so it can be listed on sparrowcodes.com/apps.
    default_headers={"X-Title": "My chatbot", **({"HTTP-Referer": os.environ["APP_URL"]} if os.environ.get("APP_URL") else {})},
)

# sparrow/auto picks a model for each message. If a provider fails before answering, the next model in FALLBACKS answers.
MODEL = os.environ.get("MODEL", "sparrow/auto")
FALLBACKS = ["deepseek/deepseek-v4.1-flash", "google/gemini-3.5-flash"]

history = [{"role": "system", "content": "You are a helpful assistant. Keep answers short."}]
spent = 0.0

print(f"Chatting with {MODEL}. Ctrl+C to quit.\n")
while True:
    try:
        text = input("you  > ").strip()
    except (EOFError, KeyboardInterrupt):
        break
    if not text:
        continue
    history.append({"role": "user", "content": text})

    raw = client.chat.completions.with_raw_response.create(
        model=MODEL,
        messages=history,
        stream=True,
        extra_body={"models": FALLBACKS},
    )
    answered_by = raw.headers.get("x-sparrow-model", MODEL)
    reply, cost = [], 0.0
    print("bot  > ", end="", flush=True)
    for chunk in raw.parse():
        if chunk.choices and chunk.choices[0].delta.content:
            piece = chunk.choices[0].delta.content
            reply.append(piece)
            print(piece, end="", flush=True)
        if chunk.usage is not None:
            cost = float(getattr(chunk.usage, "cost", 0) or 0)
    spent += cost
    print(f"\n       [{answered_by} · ${cost:.6f} · ${spent:.4f} so far]\n")
    history.append({"role": "assistant", "content": "".join(reply)})

Node.js · Text to speech

Voice reader

Turns any text file into one MP3. Long texts are split at sentence ends and joined. Short articles fit in the free daily characters.

Get the files

mkdir voice-reader && cd voice-reader
curl -fsSL --create-dirs -o read-aloud.mjs https://sparrowcodes.com/starters/voice-reader/read-aloud.mjs

Run it

export SPARROW_API_KEY="sk-sc-v1-..."
node read-aloud.mjs article.txt article.mp3 --voice F1 --lang en
Read the README →
// Turn a text file into one MP3 with SparrowCodes text to speech. Node 18+, no packages needed.
//
//   export SPARROW_API_KEY="sk-sc-v1-..."
//   node read-aloud.mjs article.txt article.mp3 --voice F1 --lang en
//
// Long texts are split at sentence ends into pieces the model accepts, spoken one by one, and joined.
import { readFile, writeFile } from "node:fs/promises";

const BASE = process.env.SPARROW_BASE_URL ?? "https://api.sparrowcodes.com/v1";
const KEY = process.env.SPARROW_API_KEY;
const MAX = 4500; // characters per request (the models accept up to 5,000)

const args = process.argv.slice(2);
const flag = (name, fallback) => {
  const i = args.indexOf(`--${name}`);
  return i >= 0 ? args.splice(i, 2)[1] : fallback;
};
const model = flag("model", "supertone/supertonic-3");
const voice = flag("voice", "F1");
const language = flag("lang", "en");
const [input, output = "speech.mp3"] = args;
if (!KEY || !input) {
  console.error("Usage: SPARROW_API_KEY=... node read-aloud.mjs <file.txt> [out.mp3] [--voice F1] [--lang en] [--model supertone/supertonic-3]");
  process.exit(1);
}

/** Split text into pieces of at most MAX characters, breaking after a sentence where possible. */
export function split(text, max = MAX) {
  const pieces = [];
  let rest = text.replace(/\s+/g, " ").trim();
  while (rest.length > max) {
    const window = rest.slice(0, max);
    const cut = Math.max(window.lastIndexOf(". "), window.lastIndexOf("! "), window.lastIndexOf("? "), window.lastIndexOf("。"));
    const at = cut > max / 2 ? cut + 1 : window.lastIndexOf(" ") > 0 ? window.lastIndexOf(" ") : max;
    pieces.push(rest.slice(0, at).trim());
    rest = rest.slice(at).trim();
  }
  if (rest) pieces.push(rest);
  return pieces;
}

const text = await readFile(input, "utf8");
const pieces = split(text);
const audio = [];
let freeLeft = null;
for (const [i, piece] of pieces.entries()) {
  const res = await fetch(`${BASE}/audio/speech`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json", "x-title": "Voice reader" },
    body: JSON.stringify({ model, voice, language, input: piece, response_format: "mp3" }),
  });
  if (!res.ok) {
    console.error(`Piece ${i + 1} failed: ${res.status} ${await res.text()}`);
    process.exit(1);
  }
  audio.push(Buffer.from(await res.arrayBuffer()));
  freeLeft = res.headers.get("x-sparrow-free-characters-left") ?? freeLeft;
  console.log(`Spoke piece ${i + 1} of ${pieces.length} (${piece.length} characters)`);
}
// MP3 files are a series of frames, so pieces can be joined end to end.
await writeFile(output, Buffer.concat(audio));
console.log(`Saved ${output}. ${text.length.toLocaleString()} characters${freeLeft ? `, ${Number(freeLeft).toLocaleString()} free characters left today` : ""}.`);

Python · Image generation

Image compare

Sends one prompt to several image models and writes a page with the results side by side and what each image cost.

Get the files

mkdir image-compare && cd image-compare
curl -fsSL --create-dirs -o compare.py https://sparrowcodes.com/starters/image-compare/compare.py

Run it

export SPARROW_API_KEY="sk-sc-v1-..."
python compare.py "a coffee mug that says ship it, studio photo"
Read the README →
"""Send one prompt to several image models and get a page that shows the results side by side, with what each cost.

    export SPARROW_API_KEY="sk-sc-v1-..."
    python compare.py "a coffee mug that says ship it, studio photo"
    open compare.html

No packages needed. Pick models with --models; the defaults are three low-cost ones.
"""
import argparse, base64, html, json, os, pathlib, time, urllib.error, urllib.request

BASE = os.environ.get("SPARROW_BASE_URL", "https://api.sparrowcodes.com/v1")
KEY = os.environ["SPARROW_API_KEY"]
DEFAULT = "black-forest-labs/flux-3-klein,bytedance-seed/seedream-5-flash,qwen/qwen-image-3"


def generate(model: str, prompt: str) -> dict:
    body = json.dumps({"model": model, "prompt": prompt, "n": 1}).encode()
    req = urllib.request.Request(f"{BASE}/images/generations", data=body, method="POST", headers={
        "authorization": f"Bearer {KEY}", "content-type": "application/json", "x-title": "Image compare",
    })
    started = time.time()
    try:
        with urllib.request.urlopen(req, timeout=300) as r:
            out = json.load(r)
    except urllib.error.HTTPError as e:
        return {"model": model, "error": json.loads(e.read() or b"{}").get("error", {}).get("message", str(e))}
    return {"model": model, "b64": out["data"][0].get("b64_json"), "cost": out.get("cost", 0), "seconds": time.time() - started}


def main():
    p = argparse.ArgumentParser()
    p.add_argument("prompt")
    p.add_argument("--models", default=DEFAULT, help="comma-separated model ids")
    p.add_argument("--out", default="compare.html")
    a = p.parse_args()

    folder = pathlib.Path("images")
    folder.mkdir(exist_ok=True)
    cards = []
    for model in [m.strip() for m in a.models.split(",") if m.strip()]:
        print(f"{model} ...", end=" ", flush=True)
        r = generate(model, a.prompt)
        if "error" in r:
            print("failed:", r["error"])
            cards.append(f'<figure><div class="err">{html.escape(r["error"])}</div><figcaption><b>{html.escape(model)}</b></figcaption></figure>')
            continue
        file = folder / (model.replace("/", "_") + ".png")
        file.write_bytes(base64.b64decode(r["b64"]))
        print(f"${r['cost']:.4f}, {r['seconds']:.1f}s")
        cards.append(f'<figure><img src="{file}" alt=""><figcaption><b>{html.escape(model)}</b><span>${r["cost"]:.4f} · {r["seconds"]:.1f}s</span></figcaption></figure>')

    page = f"""<!doctype html><meta charset="utf-8"><title>Image compare</title>
<style>body{{font:15px system-ui;margin:40px;color:#1a1f36}}h1{{font-size:20px}}.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:20px}}
figure{{margin:0}}img,.err{{width:100%;aspect-ratio:1;object-fit:cover;border-radius:8px;background:#f6f8fa}}.err{{display:grid;place-items:center;color:#c0233c;padding:16px;box-sizing:border-box}}
figcaption{{display:flex;justify-content:space-between;margin-top:8px;font-size:13px}}span{{color:#687385}}</style>
<h1>{html.escape(a.prompt)}</h1><div class="grid">{''.join(cards)}</div>"""
    pathlib.Path(a.out).write_text(page)
    print(f"Open {a.out}")


if __name__ == "__main__":
    main()

GitHub Actions · Chat completions, Auto

Pull request reviewer

A GitHub Action that reviews every pull request and posts one comment, updated on each push. Auto keeps small reviews to a fraction of a cent.

Get the files

mkdir pr-reviewer && cd pr-reviewer
curl -fsSL --create-dirs -o review.py https://sparrowcodes.com/starters/pr-reviewer/review.py
curl -fsSL --create-dirs -o .github/workflows/sparrowcodes-review.yml https://sparrowcodes.com/starters/pr-reviewer/.github/workflows/sparrowcodes-review.yml

Run it

# 1. Copy review.py to .github/sparrowcodes/review.py
# 2. Copy the workflow to .github/workflows/
# 3. Add the SPARROW_API_KEY repository secret
Read the README →
"""Review a pull request's changes with SparrowCodes and post the review as a comment. Runs in GitHub Actions.

Needs SPARROW_API_KEY (a repository secret) and GITHUB_TOKEN (provided by Actions). Set DRY_RUN=1 to print the review
instead of posting it. No packages needed.
"""
import json, os, subprocess, urllib.request

BASE = os.environ.get("SPARROW_BASE_URL", "https://api.sparrowcodes.com/v1")
MODEL = os.environ.get("REVIEW_MODEL", "sparrow/auto")
MAX_DIFF = 60_000  # characters of diff sent to the model
MARKER = "<!-- sparrowcodes-review -->"

PROMPT = """You review pull requests. Read the diff and reply in Markdown with:
1. A one-sentence summary of the change.
2. Bugs or risky changes, each with the file and line and why it matters. Say "None found" if there are none.
3. Up to three small suggestions, only if they clearly help.
Be specific and brief. Don't praise, don't repeat the diff, and don't invent problems."""


def http(method, url, body=None, headers=None):
    req = urllib.request.Request(url, data=json.dumps(body).encode() if body is not None else None, method=method, headers={"content-type": "application/json", **(headers or {})})
    with urllib.request.urlopen(req, timeout=300) as r:
        return json.loads(r.read() or b"{}"), r.headers


def main():
    base = os.environ.get("BASE_REF", "main")
    diff = subprocess.run(["git", "diff", f"origin/{base}...HEAD"], capture_output=True, text=True, check=True).stdout
    if not diff.strip():
        print("No changes to review.")
        return
    cut = len(diff) > MAX_DIFF
    out, headers = http("POST", f"{BASE}/chat/completions", {
        "model": MODEL,
        "messages": [{"role": "system", "content": PROMPT}, {"role": "user", "content": diff[:MAX_DIFF]}],
        "max_tokens": 1500,
    }, {"authorization": f"Bearer {os.environ['SPARROW_API_KEY']}", "x-title": "PR reviewer"})
    review = out["choices"][0]["message"]["content"].strip()
    cost = out.get("usage", {}).get("cost", 0)
    footer = f"\n\n---\n<sub>Reviewed by {headers.get('x-sparrow-model', MODEL)} on SparrowCodes for ${cost:.4f}{' · diff shortened to fit' if cut else ''}.</sub>"
    body = MARKER + "\n" + review + footer

    if os.environ.get("DRY_RUN"):
        print(body)
        return
    repo, pr, token = os.environ["GITHUB_REPOSITORY"], os.environ["PR_NUMBER"], os.environ["GITHUB_TOKEN"]
    gh = {"authorization": f"Bearer {token}", "accept": "application/vnd.github+json"}
    comments, _ = http("GET", f"https://api.github.com/repos/{repo}/issues/{pr}/comments?per_page=100", headers=gh)
    mine = next((c for c in comments if MARKER in c.get("body", "")), None)
    if mine:  # update our earlier comment instead of adding a new one on every push
        http("PATCH", mine["url"], {"body": body}, gh)
    else:
        http("POST", f"https://api.github.com/repos/{repo}/issues/{pr}/comments", {"body": body}, gh)
    print(f"Posted review (${cost:.4f}).")


if __name__ == "__main__":
    main()

Built something from a kit?

Name your app in the request headers and submit it to the directory.

Get a key and run a kit

Sign in with your email, create a key and start on the free plan. The voice reader fits in the free daily speech characters.

Starter kits · SparrowCodes