Skip to content

Serverless functions

Available · pip install agent-adapter (no extra)

A function host gives you a sync or async entry point and kills the container between invocations. tool_handler turns an executor into a Lambda handler(event, context). handle_envelope is the same logic for async hosts.

File Does
agent_adapter/handlers/serverless.py tool_handler: SQS batches, EventBridge, direct events; time budget from the context
agent_adapter/handlers/consume.py handle_envelope: one call or one resume, a JSON reply
examples/backend_serverless.py the Lambda handler, an async HTTP function, the review step
tests/docs/test_backend_handler_examples.py runs them with fake events, offline

Data enters as an event (JSON), goes through handle_envelope to the executor, and leaves as the return value: a reply dict, or {"batchItemFailures": [...]} for SQS.

The envelope

Envelope Means
{"tool", "arguments", "run_id"?, "call_id"?} run one call
{"approval_id"} run a call a reviewer already decided, once

Any other key is refused (ToolUsageError), so a message can never carry a decision. Without ids, the ids come from the source's delivery id (SQS messageId, EventBridge id, Lambda context.aws_request_id on a direct invoke), which stays the same on redelivery and retry: a redelivered call finds its own approval record.

Reply status Has
ok tool, run_id, call_id, result (JSON-safe)
pending_approval the same ids and approval_id; the call is parked in the store
already_done the same ids and approval_id; a redelivered resume whose decision already ran

Other failures raise a typed AdapterError.

AWS Lambda

# Module level: built once per container, reused by every warm invocation.
BACKEND = build_backend(store=SHARED_STORE)

# The Lambda entry point: an SQS batch, an EventBridge event or a direct invoke.
handler = tool_handler(BACKEND.executor, tools=TOOLS, timeout=30)
Event What handler does Returns / on error
SQS batch (Records) each message in order batchItemFailures lists failed and unreached messages; turn on ReportBatchItemFailures
SQS FIFO batch stops at the first failure that message and all later ones are failed, so order holds
EventBridge (detail-type, detail) the envelope in detail the reply; errors raise, Lambda retries, then your on-failure destination
anything else the event is the envelope (direct invoke) the reply; errors raise

Each invocation runs for min(timeout, remaining time - margin_s). Messages not done when time runs out are reported as failed, so SQS delivers them again. Set each tool's own timeout= below the Lambda timeout: a blocking sync tool cannot be cancelled.

For HTTP on Lambda, serve create_app with Mangum or the Lambda Web Adapter instead (see HTTP tool server).

Approvals across invocations

sequenceDiagram
  participant P as Producer
  participant Q as SQS
  participant H as handler
  participant St as shared ApprovalStore
  participant R as Review app
  P->>Q: {"tool": "send_invoice", ...}
  Q->>H: batch
  H->>St: pending record
  H-->>Q: acked (pending_approval)
  R->>St: record_decision (reviewer checked)
  R->>Q: {"approval_id": ...}
  Q->>H: batch
  H->>St: consume the decision (once)
  H-->>Q: acked (ok)
def review(
    approval_id: str, *, reviewer: str, approved: bool, send: Callable[[dict[str, Any]], None]
) -> Reply:
    """In the review app: store the decision, then queue a resume message for the handler."""
    reply = run_sync(
        record_decision(BACKEND, approval_id, reviewer=reviewer, approved=approved),
        timeout=30,
    )
    if reply[0] == 200 and approved:
        send({"approval_id": approval_id})  # the handler runs the call once
    return reply

The container is gone between the two invocations, so the store must be shared: SQLiteApprovalStore does not work here; use Redis, a SQL database or DynamoDB (see Approval stores). A redelivered resume runs nothing: the decision is used, so the reply is already_done and the message is acked (not sent to the dead-letter queue).

Cloud Run functions, Azure Functions, Modal

async def tool_function(body: Mapping[str, Any]) -> Reply:
    """Body of an async HTTP function (Cloud Run functions, Azure Functions v2, Modal)."""
    try:
        return 200, await handle_envelope(BACKEND.executor, body, tools=TOOLS)
    except AdapterError as exc:  # an RFC 9457 problem: 404, 422, 403 ...
        return error_reply(exc)
Host Entry Call
Cloud Run functions functions_framework.aio handler await tool_function(await request.json())
Cloud Run functions, sync @functions_framework.http handler(request.get_json())
Azure Functions v2, async def runs on the worker's loop await tool_function(req.get_json())
Azure Functions v2, def runs in a thread pool handler(req.get_json())
Modal @app.function plain Python await or handler(...); @modal.asgi_app serves create_app
Vercel Python ASGI apps deploy as is mount create_app (see FastAPI)

An HTTP function has no auth of its own. Keep it private (Cloud Run IAM, Azure function keys or Entra, Modal proxy auth), or serve create_app, which authenticates every caller.

Security

Rule Why
only trusted producers may send to the queue or invoke the function anyone who can send can run any tool you serve
pass tools= the handler runs only those tools, even if the registry has more
confirm tools still park a message never approves; only a reviewer's decision in the store does
records parked by the HTTP server are refused, by approval_id and by their run_id + call_id the server resumes them itself after re-checking the invoker
logs carry tool names and ids only arguments, results and error text stay out of WARNING logs

Warm starts

Edge Rule
invocations share the process's sync loop (LoopRunner, see Runtime) keep clients in LoopLocal: they stay open across warm invocations
a fresh loop per invocation instead pass runner=RunSync(); nothing loop-bound survives the call
the ledger and memory stores are per container budgets cap one run; shared state needs a shared store

See also: Queues, Celery, Approval.