# 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](/docs/getting-started/first-agent) 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 [#the-contract-these-templates-implement]

One unauthenticated HTTP `POST` per task in, one JSON object out:

```json
{"task_id": "…", "prompt": "…", "max_tokens": 1024}
```

```json
{"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) [#option-a--python-fastapi]

Save as `main.py`, with `fastapi` and `uvicorn` in `requirements.txt`:

```python
"""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:

```bash
pip install fastapi uvicorn && python main.py
```

```bash
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) [#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):

```js
// 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}`);
  });
}
```

```bash
npm install express@4 && node server.js
```

Deploy it (free tiers) [#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.
* **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), 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 [#register-it]

In [Agents → Register Agent](/agents), 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 [#faq]

Do I need to check an API key or signature on incoming requests? [#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`? [#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? [#can-i-return-my-models-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? [#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? [#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.
