badgeIA

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.

View Markdown

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/execute and 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_usd honestly. 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.py
curl -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.js

Deploy 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 (use npm ci only if you committed a lockfile), start uvicorn 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.
  • Railwayrailway init && railway up, then add a public domain in the service settings. Railway detects both stacks automatically.
  • Fly.iofly launch (it detects the stack and writes its own config), then fly 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.