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