The amc_sdk Python package
Serve AMC's whole run contract from one Python run function: steps, tokens, cost, renames, assignees and questions for people.
amc_sdk is AMC's library for exposing an endpoint AMC can drive. You write a run function and a small config; build_app returns a FastAPI app that serves every endpoint, with the run store, run and step state, typed events, camelCase DTOs and the SSE loop built in.
Install
amc_sdk ships with AMC and isn't on PyPI. Install it from the copy your AMC provider gives you. It depends on fastapi and pydantic; the serve extra adds uvicorn.
pip install "./amc_sdk[serve]"A complete agent
import os
from fastapi import HTTPException, Request
from amc_sdk import Agent, AgentType, HealthReport, InputParam, RunContext, StepDef, build_app
API_KEY = os.environ.get("AGENT_API_KEY", "")
async def verify_caller(request: Request) -> None:
if API_KEY and request.headers.get("api-key") != API_KEY:
raise HTTPException(status_code=401, detail="unauthorized")
def health() -> HealthReport:
if not os.environ.get("MODEL_API_KEY"):
return HealthReport(ready=False, detail="MODEL_API_KEY is not set")
return HealthReport(ready=True)
async def summarize(invoice_id: str) -> tuple[str, dict]:
# Call your model here. Return the text and the usage it reported.
return f"Summary of {invoice_id}", {"input": 4200, "output": 610, "cached": 3000}
agent = Agent(
workflow_id="invoice-summarizer",
name="Invoice Summarizer",
description="Summarizes one invoice.",
type="workflow",
steps=[
StepDef("fetch", "Fetch invoice"),
StepDef("summarize", "Summarize"),
],
agent_types=[AgentType("invoice-summarizer", "Invoice Summarizer", ["text"])],
input_parameters=[
InputParam("invoiceId", type="string", required=True, description="Invoice number"),
InputParam("ownerEmail", type="string", role="assignee", description="Who approves it"),
],
health=health,
)
@agent.run
async def run(ctx: RunContext) -> None:
invoice_id = ctx.inputs["invoiceId"]
ctx.run_renamed(f"invoice-{invoice_id}")
if ctx.inputs.get("ownerEmail"):
ctx.set_assignee(ctx.inputs["ownerEmail"])
ctx.step_started("fetch")
ctx.tool_activity("fetch", tool="get_invoice", text=f'{{"id": "{invoice_id}"}}', result='{"total": 1200}')
ctx.step_completed("fetch", token_count=0, delivered=f"Fetched {invoice_id}")
ctx.step_started("summarize", agent_name="Summarizer")
ctx.thinking("summarize", "Grouping line items by cost center")
summary, usage = await summarize(invoice_id)
ctx.step_completed(
"summarize",
token_count=usage["input"] + usage["output"],
delivered=summary,
input_tokens=usage["input"],
output_tokens=usage["output"],
cached_input_tokens=usage["cached"],
model="<model-id>",
)
ctx.set_output({"invoiceId": invoice_id, "summary": summary})
app = build_app(agent, verify_caller=verify_caller, max_concurrency=4)AGENT_API_KEY=dev-key MODEL_API_KEY=<your-model-key> uvicorn app:app --host 0.0.0.0 --port 8080The run function
The handler is async def (ctx: RunContext) -> None, set with @agent.run. The SDK runs it in the background for each POST /runs and owns the run-level status:
- Returns normally: the run is
completed, andrun.completedcarriesctx.output. - Raises: the run is
failed. Every step still pending, running or waiting is marked failed with the error text (ExceptionType: message), which AMC shows as the failure reason. - Cancelled (
POST /runs/{id}/cancel): the run iscancelled, and unfinished steps are marked failed withcancelled. - Returns normally after `ctx.mark_partial(reason)`: the run is still
completed, and/statuspublishesoutcome: "partial"withreasonasfailureReason. AMC shows Completed with issues.
ctx.inputs is the flat string map from POST /runs. The SDK doesn't parse or validate it; AMC has already validated it against your registered contract.
RunContext
| Call | What it does |
|---|---|
step_started(step_id, agent_name=None) | Marks the step running and stamps startedAt. |
step_completed(step_id, token_count, ...) | Marks it completed. Keyword arguments: delivered, input_tokens, output_tokens, cached_input_tokens, model, model_usage, agent_id. |
step_failed(step_id, detail=None) | Marks one step failed with detail as its reason, while the run continues. |
thinking(step_id, text) | Adds a thinking row to the step's activity log. |
tool_activity(step_id, tool, text="", result=None) | Adds a tool row with the tool name, its input and its result. |
activity(step_id, text, type="tool", tool=None, result=None) | Adds any activity row. Identical rows aren't repeated. |
token_delta(step_id, text) | Emits a token.delta event. |
await ask_human(step_id, question) | Parks the step on a question and returns the answer posted to /runs/{id}/messages. See the limitation below. |
set_assignee(email) | Declares who the run is about. Published as assigneeRef on the next poll. None does nothing. |
run_renamed(name) | Renames this run. Published as runName and as a run.renamed event. Blank does nothing. |
mark_partial(reason) | Declares that the run finished with issues; reason says what wasn't done. It doesn't end the run. Once the handler returns, /status publishes outcome: "partial" and failureReason. A blank reason raises ValueError. The reason is trimmed and capped at 2000 characters, and the last call wins. Raising still fails the run, and on a failed or cancelled run the call is ignored. AMC never derives a partial outcome from failed steps. |
set_cost(cost_usd) | The run's own cost. AMC prefers it over its own pricing. |
set_step_tokens(step_id, token_count, ...) | Sets a step's tokens directly, for the failure path. |
set_output(dict) | The run's output, sent with run.completed. |
emit(event_type, step_id=None, fragment=None) | Appends a raw event. Rarely needed. |
Report spend on the failure path
A run that raises before step_completed reports zero tokens unless you record them first:
try:
summary, usage = await summarize(invoice_id)
except Exception:
ctx.set_step_tokens("summarize", token_count=spent_in + spent_out,
input_tokens=spent_in, output_tokens=spent_out,
model="<model-id>", cached_input_tokens=spent_cached)
ctx.set_cost(spent_usd)
raise # the run fails, with the error as its reasonConfig objects
| Object | Fields |
|---|---|
Agent | workflow_id, name, description, type ("workflow" by default), steps, version ("1"), agent_types, input_parameters, run_metadata, health. |
StepDef | step_id, name, description=None. One per step, in order. step_id is what AMC keys the step by. |
InputParam | name, type="string", required=False, format, description, default, enum_values, role. |
AgentType | key, display_name, capabilities. |
HealthReport | ready=True, detail=None, status=None. |
build_app needs a handler and at least one StepDef. Every declared step is listed on /status from the start of each run, as pending.
Health
With no health callable, /health returns {"status": "healthy", "service": "<workflow_id>"}. With one, ready=False returns 503 with status: "not_configured" and your detail. AMC's registration check treats any non-2xx as Unreachable, so check your own configuration (model keys, required files) here: a misconfigured agent can't be registered.
build_app options
| Option | Default | Purpose |
|---|---|---|
verify_caller | Allow everyone | A FastAPI dependency run before every request. Raise HTTPException to reject. Supply one: the default is not a security control. |
on_startup | None | callable(agent), sync or async, run at startup. |
max_concurrency | Unbounded | Cap on runs executing at once in this process. Extra runs wait their turn while reported as running. |
max_runs | 200 | How many runs to keep in memory. The oldest finished runs are evicted first; running runs never are. |
sse_poll_seconds | 0.1 | How often the SSE stream checks for new events. |
cors_allow_origins | ["*"] | CORS origins. |
title | The agent's name | The OpenAPI title. |
Limitations in 0.1.0
- Questions don't open interventions yet.
ask_humanreports the run aswaiting, and AMC opens an intervention only forhuman_intervention. The run shows as Intervention with nothing to answer. Until this changes, an agent that must ask people should serve the contract directly, as in Ask a person mid-run. - The live view shows completion and renames only. The SDK's step, token and failure events carry plain-text fragments, which AMC's live view can't read and drops. The poll still records everything, so the run page catches up within one poll.
- Runs live in memory. A restart loses them: AMC's polls and cancels for those runs then fail, and the runs stay live in AMC (see Restarts). Keep
max_runslarge enough that finished runs stay readable for as long as people review them. - Declarations stay on your side. The SDK publishes
input_parametersandstepsatGET /workflows/{id}, but AMC doesn't read them. Enter the same input contract and declared steps when you register.
Test with FastAPI's TestClient
import time
from fastapi.testclient import TestClient
from app import app
def wait_for(client, run_id, status):
for _ in range(100):
body = client.get(f"/runs/{run_id}/status").json()
if body["status"] == status:
return body
time.sleep(0.02)
raise AssertionError(body)
def test_run_completes():
with TestClient(app) as client:
run = client.post("/runs", json={"inputs": {"invoiceId": "INV-1042"}}).json()
status = wait_for(client, run["orchestratorRunId"], "completed")
assert status["runName"] == "invoice-INV-1042"
tokens = client.get(f"/runs/{run['orchestratorRunId']}/tokens").json()
assert tokens["inputTokens"] == 4200
def test_missing_input_fails_the_run():
with TestClient(app) as client:
run = client.post("/runs", json={"inputs": {}}).json()
status = wait_for(client, run["orchestratorRunId"], "failed")
assert "KeyError" in status["steps"][0]["failureReason"]Use TestClient as a context manager. Without it, the client's event loop closes between requests and cancels the background run.
Related
Last updated 2026-10-21