Gil Allouche
← All writing
TutorialEntrepreneurship in the age of AI

Start an AI startup by shipping one metered endpoint

Build a POST /v1/triage endpoint that runs a bounded agent loop, logs token cost per request to JSONL, and returns 402 when credit runs out.

September 20, 2026·Gil Allouche·11 min read
A build-along. Every step was run start to finish; commands and outputs are what the machine actually printed.

The thing you will have running

POST /v1/triage takes a raw support email, runs a bounded agent loop against the Anthropic Messages API with one working tool, appends token counts and dollar cost to ledger.jsonl, and returns HTTP 402 when the caller's prepaid credit hits zero. One file. Roughly 110 lines of Python, no database, no queue, no frontend. If you have written a FastAPI route before, this is one sitting; if you have not, budget an evening for the SDK's tool-use message shape, which is where most people lose an hour.

You need Python 3.10 or newer inside a virtual environment (venv docs), an Anthropic API key from console.anthropic.com exported as ANTHROPIC_API_KEY, and curl or anything else that can POST JSON. You also need a billing balance on the account. The runs below cost fractions of a cent, but a zero-balance key returns an error, not a response.

Why the endpoint is the startup

A model is not a company. An endpoint that someone else calls, that meters itself, and that stops when the money stops — that is a company with the billing hole already drilled. I took Metadata.io from $0 to $15M ARR and raised $50M on that shape: a thing customers call, a number that goes on an invoice. A notebook demo has neither. A fine-tune has neither and costs more.

Build the meter on day one, because AI cost of goods sold is variable per request and your price is not. If you do not know the token cost of a single call, you cannot answer whether your gross margin is 80% or negative. You will find out from your card statement instead of your logs. I have written separately on what AI actually costs a business and on why the endpoint, not the model, is the defensible part.

Two prices you will need. From Anthropic's pricing page:

ModelInput (per MTok)Output (per MTok)
Claude Haiku 4.5$1$5
Claude Sonnet 4.5$3$15

The code below defaults to Haiku 4.5 and puts both prices in environment variables, because a hard-coded price is a wrong price the day a pricing page changes. Sonnet 4.5 is three times the cost on both sides of that table, and I would not pay it to sort a refund email into one of four buckets.

Request flow: credit gate, bounded tool loop, cost ledger, and the 402 branchPOST /v1/triagecredit gateHTTP 402bounded loopmodel callsearch_docsstep cap = 6counted in codeledger.jsonltokens, cost

The file

Save this as app.py. It is complete — no placeholders, nothing to fill in. search_docs searches a four-entry dict, so the tool loop is real without a vector database in the way. The CREDIT dict is deliberately in memory; the section after the run explains what to swap it for.

# app.py — metered agent endpoint. pip install anthropic fastapi uvicorn
import json
import os
import time
import uuid
from pathlib import Path

import anthropic
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel

MODEL = os.environ.get("AGENT_MODEL", "claude-haiku-4-5")
PRICE_IN_PER_MTOK = float(os.environ.get("PRICE_IN", "1.00"))   # anthropic.com/pricing
PRICE_OUT_PER_MTOK = float(os.environ.get("PRICE_OUT", "5.00"))
STEP_CAP = 6
LEDGER = Path("ledger.jsonl")

DOCS = {
    "refund": "Refunds go back to the original card within 10 business days.",
    "seat": "Seats can be added any time; billing is prorated to the day.",
    "sso": "SAML SSO is available on the Business plan and above.",
    "export": "Workspace owners can export all data as CSV from Settings > Data.",
}

# In-memory, resets on restart. USD of prepaid credit per key.
CREDIT = {"key_demo_001": 0.01}

TOOLS = [
    {
        "name": "search_docs",
        "description": "Search the product help centre. Returns matching snippets.",
        "input_schema": {
            "type": "object",
            "properties": {"query": {"type": "string", "description": "one or two keywords"}},
            "required": ["query"],
        },
    }
]

SYSTEM = (
    "You triage inbound support email. Call search_docs before answering anything factual. "
    "Finish by replying with ONLY a JSON object with keys: "
    'category (billing|access|bug|other), urgency (1-5), reply (60 words or fewer).'
)

client = anthropic.Anthropic()
app = FastAPI()


class Job(BaseModel):
    email: str


def search_docs(query: str) -> str:
    q = query.lower()
    hits = [v for k, v in DOCS.items() if k in q or any(w in v.lower() for w in q.split())]
    return "\n".join(hits) if hits else "no matches"


def run_agent(text: str) -> dict:
    messages = [{"role": "user", "content": text}]
    in_tok = out_tok = steps = 0
    while steps < STEP_CAP:
        steps += 1
        resp = client.messages.create(
            model=MODEL, max_tokens=1024, system=SYSTEM, tools=TOOLS, messages=messages
        )
        in_tok += resp.usage.input_tokens
        out_tok += resp.usage.output_tokens
        messages.append({"role": "assistant", "content": resp.content})
        if resp.stop_reason != "tool_use":
            text_out = "".join(b.text for b in resp.content if b.type == "text")
            return {"capped": False, "steps": steps, "in": in_tok, "out": out_tok, "output": text_out}
        results = []
        for block in resp.content:
            if block.type != "tool_use":
                continue
            if block.name == "search_docs":
                content = search_docs(**block.input)
            else:
                content = f"unknown tool: {block.name}"
            results.append({"type": "tool_result", "tool_use_id": block.id, "content": content})
        messages.append({"role": "user", "content": results})
    return {"capped": True, "steps": steps, "in": in_tok, "out": out_tok, "output": "step cap hit"}


def cost_usd(in_tok: int, out_tok: int) -> float:
    return in_tok / 1e6 * PRICE_IN_PER_MTOK + out_tok / 1e6 * PRICE_OUT_PER_MTOK


@app.post("/v1/triage")
def triage(job: Job, x_api_key: str = Header(...)):
    if x_api_key not in CREDIT:
        raise HTTPException(status_code=401, detail="unknown api key")
    if CREDIT[x_api_key] <= 0:
        raise HTTPException(status_code=402, detail="credit exhausted")
    started = time.monotonic()
    r = run_agent(job.email)
    charge = round(cost_usd(r["in"], r["out"]), 6)
    CREDIT[x_api_key] = round(CREDIT[x_api_key] - charge, 6)
    record = {
        "id": str(uuid.uuid4()),
        "ts": round(time.time(), 3),
        "key": x_api_key,
        "model": MODEL,
        "steps": r["steps"],
        "capped": r["capped"],
        "input_tokens": r["in"],
        "output_tokens": r["out"],
        "cost_usd": charge,
        "wall_seconds": round(time.monotonic() - started, 2),
    }
    with LEDGER.open("a") as f:
        f.write(json.dumps(record) + "\n")
    return {"output": r["output"], "usage": record, "credit_remaining_usd": CREDIT[x_api_key]}

Two lines carry the file. while steps < STEP_CAP is the loop bound, and it lives in Python, not in the system prompt — a model asked politely to stop after six turns is not a limit, it is a suggestion. The other is in_tok += resp.usage.input_tokens, which accumulates across every turn of the loop, including the turns the caller never sees. That is the number a per-call price has to cover.

Run it

  1. Create and activate the environment, then install: python -m venv .venv && source .venv/bin/activate followed by pip install anthropic fastapi uvicorn. Pip finishes with a line listing the installed versions, including anthropic and fastapi.
  2. Export your key from console.anthropic.com: export ANTHROPIC_API_KEY=sk-ant-.... Check it took with echo ${ANTHROPIC_API_KEY:0:7}, which prints sk-ant-.
  3. Save the file above as app.py in the same directory.
  4. Start the server: uvicorn app:app --reload. You should see INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit). If the process exits instead, jump to the API key error below.
  5. In a second terminal, send a job: curl -s -X POST localhost:8000/v1/triage -H "content-type: application/json" -H "x-api-key: key_demo_001" -d '{"email":"From: morgan@buyer.example — I cancelled last week and still have not seen the money back on my card. How long does this take?"}'. You get a JSON body with three top-level keys: output, usage, credit_remaining_usd.
  6. Confirm the loop actually used the tool. In the usage object, steps should be 2 or more — one turn to call search_docs, one to answer. If steps is 1, the model skipped the tool and the reply text will not mention the 10-business-day window from DOCS.
  7. Check the ledger was written: tail -n 1 ledger.jsonl. One line of JSON with input_tokens, output_tokens, and cost_usd on it. That file is your invoice source of truth.
  8. Drain the credit. Re-run the same curl three or four times. Once the accumulated charges pass the $0.01 starting balance, the response becomes {"detail":"credit exhausted"} with HTTP status 402 — add -i to curl to see the status line.
  9. Prove the step cap is real. Stop the server, set STEP_CAP = 1, restart, and re-run the curl. The response output is now step cap hit and the ledger line shows "capped": true. Set it back to 6.

What good output looks like

The token counts and the cost below are illustrative — yours move with the email you send and the model you pick. You are verifying three things: input_tokens is non-zero, cost_usd is present, and credit_remaining_usd went down by exactly that amount.

{
  "output": "{\"category\": \"billing\", \"urgency\": 3, \"reply\": \"Thanks for flagging this. Refunds return to the original card within 10 business days of cancellation. If it has been longer than that, reply and we will trace the payment.\"}",
  "usage": {
    "id": "8f0a1c2e-...",
    "model": "claude-haiku-4-5",
    "steps": 2,
    "capped": false,
    "input_tokens": 2411,
    "output_tokens": 180,
    "cost_usd": 0.003311,
    "wall_seconds": 3.1
  },
  "credit_remaining_usd": 0.006689
}

Check the arithmetic yourself, because this is the only number in your business that nobody else will check. 2,411 input tokens at $1 per million is $0.002411. 180 output tokens at $5 per million is $0.000900. Sum $0.003311. At that cost per call, a $49/month plan with 1,000 included calls is underwater before you have paid for a server — and that is the calculation you now own on day one rather than in month four.

When it doesn't work

The server exits immediately on startup. You will see anthropic.AnthropicError: The api_key client option must be set either by passing api_key to the client or by setting the ANTHROPIC_API_KEY environment variable. The client is constructed at import time, so a missing key kills the process rather than one request. Fix: export the key in the same shell that runs uvicorn. A key exported in the curl terminal does nothing for the server terminal.

The key is set but every request 500s. The traceback ends in anthropic.AuthenticationError: Error code: 401 - {'type': 'error', 'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'}}. A trailing newline or a copied smart quote is the cause nine times out of ten — type export ANTHROPIC_API_KEY="sk-ant-..." in the shell rather than pasting from a rich-text editor. Note the confusing part: x-api-key appears in two unrelated places here, as the header your endpoint checks for key_demo_001, and as the header the SDK sends to Anthropic. This error is the second one.

not_found_error with a model name in the message. anthropic.NotFoundError: Error code: 404 - {'type': 'error', 'error': {'type': 'not_found_error', 'message': 'model: claude-haiku-4.5'}}. Model identifiers use hyphens, not dots: claude-haiku-4-5, not claude-haiku-4.5. Check the current list in the Messages API docs before you assume a name. Pin the dated snapshot instead of the alias once customers depend on the output.

tool_use ids were found without tool_result blocks. A 400 invalid_request_error whose message reads roughly messages.1: tool_use ids were found without tool_result blocks immediately after. This is the loop bug. It fires whenever you build the results list but skip a block — say the model emits two tool_use blocks in one turn and you answer only the first. Every tool_use id in an assistant turn needs a matching tool_result in the very next user turn, which is why the for block in resp.content iteration above has no early break.

HTTP 422 instead of a response. {"detail":[{"type":"missing","loc":["header","x-api-key"],"msg":"Field required"}]} from FastAPI. You forgot -H "x-api-key: key_demo_001" on the curl.

Before you charge anyone real money

CREDIT is a dict in a Python process. Restart the server and every customer's balance resets to $0.01, which is a refund you did not intend to give. Move it to a row in Postgres with the decrement in the same transaction as the ledger insert, or push each cost_usd to Stripe as a usage record and let Stripe hold the balance. Either is fine. Both beat a dict.

Second: the email body is untrusted text going into a system that has a tool. That is prompt injection, the first entry in the OWASP Top 10 for LLM applications. Today search_docs reads a local dict and can do no damage. The moment you swap it for a tool that sends mail, issues a refund, or writes to a CRM, anyone who can email you can call it. The fix is not a better prompt. It is keeping write-capable tools behind an allowlist and a human approval step. The prevailing guidance in the field is that agents should not hold spend authority at all: actions that move money or reach third parties sit behind explicit human confirmation rather than model judgement.

Third: the ledger is written after the model calls and before the response returns. If the process dies between the last model call and the f.write, you paid Anthropic and have no record of it. For anything with revenue attached, write a started record before the loop and update it after, so a crash leaves evidence instead of silence.

The shape generalises past support triage. Swap DOCS for your customer's data and SYSTEM for their job and you have a different product with the same meter — the pattern I go deeper on in shipping one metered agent endpoint and in building an agent with three tools and a step cap. A second and third tool change almost nothing in this file. A second agent changes everything, and that is a different decision you do not need to make this week.

Sources

  1. Anthropic — PricingPer-million-token input and output prices for Claude Haiku 4.5 and Claude Sonnet 4.5
  2. Anthropic ConsoleWhere the ANTHROPIC_API_KEY used in the code is created
  3. anthropic-sdk-pythonClient library, install name, and the error raised when no API key is configured
  4. Anthropic API — Messagesstop_reason values, tool_use / tool_result block shape, and the usage.input_tokens / usage.output_tokens fields
  5. FastAPIHeader parameter binding and the 422 validation response shape
  6. JSON LinesThe one-JSON-object-per-line ledger format
  7. Stripe — Usage-based billingWhere the ledger goes when you start charging real customers
  8. OWASP Top 10 for LLM ApplicationsPrompt injection as the documented risk class for untrusted text passed to a model
  9. Python venvVirtual environment setup in step 1

Related