Browse the docs

Test your endpoint locally

A complete minimal endpoint, and the calls to make against it in the order AMC makes them.

Before you startPython 3.10 or later, and `pip install fastapi uvicorn`.View as Markdown

This service implements the whole run contract in about 150 lines, with an in-memory store and two steps. Use it to see the wire format working, or as the starting shape for a service in any language.

app.py
import asyncio
import json
import os
import uuid
from datetime import datetime, timezone

from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field

API_KEY = os.environ.get("AGENT_API_KEY", "")
STEPS = [("fetch", "Fetch invoice"), ("summarize", "Summarize")]


def now() -> str:
    return datetime.now(timezone.utc).isoformat()


def check_key(api_key: str | None = Header(default=None, alias="api-key")) -> None:
    if API_KEY and api_key != API_KEY:
        raise HTTPException(status_code=401, detail="unauthorized")


app = FastAPI(dependencies=[Depends(check_key)])
runs: dict[str, dict] = {}


class StartRun(BaseModel):
    orchestratorWorkflowId: str | None = None
    versionId: str | None = None
    inputs: dict[str, str] = Field(default_factory=dict)
    idempotencyKey: str | None = None


def emit(run: dict, event_type: str, step_id: str | None = None, fragment: dict | None = None) -> None:
    run["events"].append({
        "eventType": event_type,
        "orchestratorRunId": run["id"],
        "stepId": step_id,
        "payloadFragment": json.dumps(fragment) if fragment is not None else None,
        "at": now(),
    })


def set_step(run: dict, step_id: str, status: str, **fields) -> None:
    step = next(s for s in run["steps"] if s["stepId"] == step_id)
    step["status"] = status
    step.update(fields)
    emit(run, "step.status", step_id, {"status": status})


async def work(run: dict) -> None:
    try:
        invoice = run["inputs"].get("invoiceId", "unknown")
        set_step(run, "fetch", "running", startedAt=now())
        await asyncio.sleep(1)  # your real work goes here
        set_step(run, "fetch", "completed", completedAt=now(), delivered=f"Fetched {invoice}")

        set_step(run, "summarize", "running", startedAt=now())
        await asyncio.sleep(1)
        set_step(run, "summarize", "completed", completedAt=now(), delivered="Summary text",
                 tokenCount=1200, inputTokens=1000, outputTokens=200, model="<model-id>")
        run["status"] = "completed"
        emit(run, "run.completed", fragment={"summary": "Summary text"})
    except asyncio.CancelledError:
        run["status"] = "cancelled"
        emit(run, "run.status", fragment={"status": "cancelled"})
        raise
    except Exception as exc:
        run["status"] = "failed"
        run["failureReason"] = f"{type(exc).__name__}: {exc}"
        emit(run, "run.status", fragment={"status": "failed", "reason": run["failureReason"]})
    finally:
        run["finishedAt"] = now()


def run_dto(run: dict) -> dict:
    return {"orchestratorRunId": run["id"], "orchestratorWorkflowId": run["workflowId"],
            "status": run["status"], "startedAt": run["startedAt"], "finishedAt": run["finishedAt"]}


def get_run(run_id: str) -> dict:
    if run_id not in runs:
        raise HTTPException(status_code=404, detail="run not found")
    return runs[run_id]


@app.get("/health")
async def health():
    return {"status": "healthy"}


@app.post("/runs")
async def start_run(body: StartRun):
    run_id = "run_" + uuid.uuid4().hex
    run = runs[run_id] = {
        "id": run_id, "workflowId": body.orchestratorWorkflowId or "invoice-summarizer",
        "status": "running", "startedAt": now(), "finishedAt": None, "inputs": body.inputs,
        "failureReason": None, "events": [],
        "steps": [{"stepId": sid, "name": name, "status": "pending", "tokenCount": 0}
                  for sid, name in STEPS],
    }
    run["task"] = asyncio.create_task(work(run))
    return run_dto(run)


@app.post("/runs/{run_id}/cancel")
async def cancel_run(run_id: str):
    run = get_run(run_id)
    if run["status"] in ("running", "human_intervention"):
        run["task"].cancel()
        await asyncio.sleep(0)
    return run_dto(run)


@app.get("/runs/{run_id}/status")
async def run_status(run_id: str):
    run = get_run(run_id)
    started = [s for s in run["steps"] if s["status"] != "pending"]
    body = {"orchestratorRunId": run_id, "status": run["status"], "currentStep": len(started),
            "totalSteps": len(run["steps"]),
            "steps": [{k: v for k, v in s.items() if k != "delivered" or s["status"] == "waiting"}
                      for s in run["steps"]]}
    if run["failureReason"]:
        body["failureReason"] = run["failureReason"]
    return body


@app.get("/runs/{run_id}/tokens")
async def run_tokens(run_id: str):
    run = get_run(run_id)
    total = sum(s.get("tokenCount", 0) for s in run["steps"])
    return {"orchestratorRunId": run_id, "agentTokens": total, "hostTokens": 0, "totalTokens": total,
            "inputTokens": sum(s.get("inputTokens", 0) for s in run["steps"]),
            "outputTokens": sum(s.get("outputTokens", 0) for s in run["steps"]),
            "costUsd": 0}


@app.get("/runs/{run_id}/steps/{step_id}/content")
async def step_content(run_id: str, step_id: str):
    step = next((s for s in get_run(run_id)["steps"] if s["stepId"] == step_id), None)
    if step is None:
        return JSONResponse(status_code=404, content={"error": "step not found"})
    return {"stepId": step_id, "action": None, "delivered": step.get("delivered"),
            "reasoning": None, "activityLog": []}


@app.get("/runs/{run_id}/events")
async def run_events(run_id: str):
    run = get_run(run_id)

    async def stream():
        sent = 0
        while True:
            while sent < len(run["events"]):
                yield f"data: {json.dumps(run['events'][sent])}\n\n"
                sent += 1
            if run["finishedAt"] and sent >= len(run["events"]):
                return
            await asyncio.sleep(0.2)

    return StreamingResponse(stream(), media_type="text/event-stream")

To add questions for people, add the ask_human helper and the /messages route from Ask a person mid-run.

Drive it the way AMC does

Terminal 1
AGENT_API_KEY=dev-key uvicorn app:app --port 8080
Terminal 2
H='api-key: dev-key'
curl -s -H "$H" localhost:8080/health                 # registration check
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/health   # 401 without the key

RUN=$(curl -s -H "$H" -H 'Content-Type: application/json' \
  -d '{"orchestratorWorkflowId":"invoice-summarizer","versionId":null,"inputs":{"invoiceId":"INV-1042"},"idempotencyKey":null}' \
  localhost:8080/runs | python3 -c 'import sys, json; print(json.load(sys.stdin)["orchestratorRunId"])')

curl -s -N -H "$H" localhost:8080/runs/$RUN/events    # live view, closes when the run ends
curl -s -H "$H" localhost:8080/runs/$RUN/status       # the poll
curl -s -H "$H" localhost:8080/runs/$RUN/tokens       # the poll
curl -s -H "$H" localhost:8080/runs/$RUN/steps/summarize/content

Check before you register

  • /health returns 2xx with the right credential and 401 without it.
  • POST /runs returns in well under a second, with status running.
  • /status shows every step from the start, and ends completed with the last step's tokens in.
  • Every step that used a model reports inputTokens, outputTokens and model.
  • A forced failure ends failed with a readable failureReason.
  • Cancel works while the run is running and while it is waiting on a person.
  • Finished runs stay readable at /status and /steps/{stepId}/content.

Register a test endpoint

  • AMC must be able to reach the endpoint from its own network. For an AMC in the cloud, expose your local port over an https tunnel or deploy to a test host.
  • Use API key authentication. Key mode accepts http endpoints; managed identity needs https and an approved host.
  • Choose Flight Director / custom, check This resource uses the AMC SDK (or enter a path segment such as invoice-summarizer), and enter the endpoint and key. See Register your agent.

Last updated 2026-10-21