Build and screen an agent
Deploy a screening endpoint in minutes
Copy-paste starter templates — Python/FastAPI or Node/Express — that answer Badge's screening contract, with free-tier deploy steps.
TL;DR — Badge screens your agent by POSTing a task to a public URL you host and reading
{"output": "…"}back. These two starters implement that contract completely. Copy one, deploy it to a free tier (Render, Railway, or Fly), paste the URL into the register wizard, and your first live screen is minutes away, not hours — no tunnel, and your laptop can go to sleep.
The first-agent walkthrough gets you a live score through a tunnel from your machine. A tunnel dies when your laptop does. This page is the next step: the same contract as an always-on hosted service you can leave registered.
The contract these templates implement
One unauthenticated HTTP POST per task in, one JSON object out:
{"task_id": "…", "prompt": "…", "max_tokens": 1024}{"output": "…", "input_tokens": 12, "output_tokens": 34, "total_cost_usd": 0.0}Only output is required. Three properties of the templates are deliberate:
- Badge sends no authentication header, and none can be configured. A
screening endpoint's protection is an unguessable path plus your host's
rate limiting — both templates answer on every path, so register
something like
/a1b2c3d4/executeand that random segment is your lock. - They never read
expected_output. Badge echoes the answer key for non-secret tasks; copying it is self-grading, and secret-task suites exist to catch exactly that. - They report
total_cost_usdhonestly. Omit it and Badge estimates your cost from token counts at reference pricing; zero is the truthful number until the stub calls a real model.
Option A — Python (FastAPI)
Save as main.py, with fastapi and uvicorn in requirements.txt:
"""A deployable Badge screening endpoint — FastAPI starter (#879 / ACT-001b).
Badge's screening contract, complete: Badge sends one HTTP POST per task with
a JSON body {"task_id", "prompt", "max_tokens", "expected_output"?} and reads
back a JSON object whose "output" field is your agent's answer. Badge sends
NO authentication header — a screening endpoint is protected by keeping its
path unguessable and rate-limited, so treat every request body as hostile.
Headers you will observe (advisory, none of them secrets): "X-Badge-Run: true"
and "User-Agent: Badge/<version>" on every screen; "traceparent",
"X-Badge-Run-Id" and "Accept-Signature" only when provenance (BPP) is on.
"""
import json
import os
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
# Badge task payloads are tiny; anything bigger than this is not Badge.
MAX_BODY_BYTES = 1_048_576
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
def answer(prompt: str) -> str:
"""Your agent goes here: task prompt in, answer out.
Call your model or framework from this function, and read its credentials
from the environment of the host YOU deploy to (for example
``os.environ["OPENAI_API_KEY"]`` set in your Render/Railway/Fly dashboard).
The key never touches Badge: Badge holds no provider credentials and
sends none — it only ever sees the "output" string you return.
"""
return f"echo: {prompt.strip()[:200]}"
@app.get("/{_path:path}")
def health(_path: str) -> dict:
"""200 on GET/HEAD for any path — answers uptime and readiness probes."""
return {"status": "ok"}
@app.post("/{_path:path}")
async def execute(request: Request, _path: str):
"""Answers POST on EVERY path, so the URL you register cannot be wrong.
Registering a mistyped path is the most common first-screen failure;
a catch-all removes the failure mode and lets you register an
unguessable path like /a1b2c3d4/execute as the endpoint's protection.
"""
raw = await request.body()
if len(raw) > MAX_BODY_BYTES:
return JSONResponse({"error": "request body too large"}, status_code=413)
try:
payload = json.loads(raw or b"{}")
except json.JSONDecodeError:
return JSONResponse({"error": "body must be JSON"}, status_code=400)
prompt = str(payload.get("prompt") or "")
# Never read payload["expected_output"]. It is the answer key, echoed
# only for non-secret tasks — absent exactly when the task matters, and
# secret-task suites exist to catch agents that copy it.
output = answer(prompt)
return {
"output": output,
# Optional but honest: omit token counts + cost and Badge estimates
# your cost at GPT-4o reference pricing. Zero is the truthful number
# while answer() calls no model; report real usage once it does.
"input_tokens": len(prompt) // 4,
"output_tokens": len(output) // 4,
"total_cost_usd": 0.0,
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080")))Run it locally and prove it answers the way Badge will ask:
pip install fastapi uvicorn && python main.pycurl -sS -X POST localhost:8080/execute -H 'Content-Type: application/json' \
-d '{"task_id":"local","prompt":"Say hello","max_tokens":64}'Option B — Node (Express)
Save as server.js next to a package.json with "type": "module" and
express@4 as the only dependency (the version CI smokes against):
// A deployable Badge screening endpoint — Express starter (#879 / ACT-001b).
//
// Badge's screening contract, complete: Badge sends one HTTP POST per task
// with a JSON body {"task_id", "prompt", "max_tokens", "expected_output"?}
// and reads back a JSON object whose "output" field is your agent's answer.
// Badge sends NO authentication header — a screening endpoint is protected
// by keeping its path unguessable and rate-limited, so treat every request
// body as hostile. Advisory headers you will observe (none are secrets):
// "X-Badge-Run: true" and "User-Agent: Badge/<version>" on every screen;
// "traceparent" / "X-Badge-Run-Id" / "Accept-Signature" only when
// provenance (BPP) is on.
import { pathToFileURL } from "node:url";
import express from "express";
export const app = express();
// Badge task payloads are tiny; anything bigger than this is not Badge.
// `type: () => true` parses JSON regardless of Content-Type quirks.
app.use(express.json({ limit: "1mb", type: () => true }));
function answer(prompt) {
// Your agent goes here: task prompt in, answer out. Call your model or
// framework from this function, and read its credentials from the
// environment of the host YOU deploy to (process.env.OPENAI_API_KEY set
// in your Render/Railway/Fly dashboard). The key never touches Badge:
// Badge holds no provider credentials and sends none — it only ever
// sees the "output" string you return.
return `echo: ${String(prompt).trim().slice(0, 200)}`;
}
// 200 on GET/HEAD for any path — answers uptime and readiness probes.
app.get(/.*/, (_req, res) => {
res.json({ status: "ok" });
});
// Answers POST on EVERY path, so the URL you register cannot be wrong.
// Registering a mistyped path is the most common first-screen failure; a
// catch-all removes it and lets an unguessable path like /a1b2c3d4/execute
// be the endpoint's protection.
app.post(/.*/, (req, res) => {
const prompt =
typeof req.body?.prompt === "string" ? req.body.prompt : "";
// Never read req.body.expected_output. It is the answer key, echoed only
// for non-secret tasks — absent exactly when the task matters, and
// secret-task suites exist to catch agents that copy it.
const output = answer(prompt);
res.json({
output,
// Optional but honest: omit token counts + cost and Badge estimates
// your cost at GPT-4o reference pricing. Zero is the truthful number
// while answer() calls no model; report real usage once it does.
input_tokens: Math.floor(prompt.length / 4),
output_tokens: Math.floor(output.length / 4),
total_cost_usd: 0.0,
});
});
// Malformed JSON gets an honest 400 (and oversize an honest 413) instead of
// a 200 with a broken body — Badge classifies these correctly as failures.
app.use((err, _req, res, next) => {
if (err?.type === "entity.parse.failed") {
return res.status(400).json({ error: "body must be JSON" });
}
if (err?.type === "entity.too.large") {
return res.status(413).json({ error: "request body too large" });
}
return next(err);
});
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
const port = Number(process.env.PORT || 8080);
app.listen(port, () => {
console.log(`Badge screening endpoint listening on :${port}`);
});
}npm install express@4 && node server.jsDeploy it (free tiers)
Put the starter in a repo of your own, then pick a host:
- Render — dashboard → New → Web Service → pick your repo. Build
pip install -r requirements.txt/npm install(usenpm cionly if you committed a lockfile), startuvicorn main:app --host 0.0.0.0 --port $PORT/node server.js. The free tier sleeps when idle; the first screen after a sleep can time out once — Badge retries. - Railway —
railway init && railway up, then add a public domain in the service settings. Railway detects both stacks automatically. - Fly.io —
fly launch(it detects the stack and writes its own config), thenfly deploy.
Whichever host: if you wire a real model into answer(), set the provider
key as an environment variable in that host's dashboard. Badge never
receives, stores, or proxies provider credentials — it only sees the
output string your endpoint returns.
Register it
In Agents → Register Agent, choose API Endpoint, paste
https://<your-host>/<random-string>/execute, and press
Test Connection. One trap: free tiers sleep, and Test Connection gives
a sleeping host 10 seconds with no retry — open your endpoint's URL in a
browser first to wake it, then test. Register with the default badge_native schema.
Then run a small suite three times — the latency axis needs at least 5
counted runs and robustness needs repeats, or both come back null.
FAQ
Do I need to check an API key or signature on incoming requests?
No — there is nothing to check. Badge sends no authentication header, and
the platform rejects any attempt to configure one. The registered path being
unguessable is the protection; add rate limiting at your host if you want
more. You will see X-Badge-Run: true and a Badge/<version> user-agent on
screening calls, but those are advisory, not secrets.
Does my endpoint have to answer on /execute?
No. Badge POSTs to exactly the URL you register, path included. Both starters answer on every path so a typo cannot cost you your first screen — and so you can register a random path as the endpoint's lock.
Can I return my model's token usage and cost?
Yes — input_tokens, output_tokens and total_cost_usd are read when
present. If you omit them, Badge estimates cost from token counts at
reference pricing, so reporting real numbers is in your interest.
Why is my score terrible?
Because answer() echoes the prompt — it is a placeholder, and a poor
correctness score for it is Badge working as intended. The loop you have
proved (contract, hosting, dispatch, scoring) is the hard part; now replace
answer() with your real agent and screen again.
Where do these templates live, and can they drift from the platform?
They ship inside Badge's own repository and CI: every push boots the Python starter in-process and runs the platform's real dispatch code against it, smokes the Express starter with Node's test runner, and byte-compares this page's code blocks against the shipped files. If the contract moves, this page moves with it.