Skip to content

Run a verification service

This page is for teams who want one small service that other systems can ask "does this agent's manifest check out?" You load a signed manifest, check it against values the service holds itself, and return the verdict over HTTP. You run it locally first, then package the same service in a container.

The service also loads signed revocations (records that withdraw a manifest early) and uses them when it checks.

Prepare the demo inputs

Complete the first-manifest tutorial. Append this block to first_manifest.py and run it once. It saves the demo's independently configured context and public revocation key; no private key is written. Use a new data directory for this exercise.

from agent_manifest._revocation import sign_revocation

data = Path("data")
data.mkdir(exist_ok=False)
(data / "manifest.json").write_text(json.dumps(record), encoding="utf-8")
(data / "context.json").write_text(context.model_dump_json(), encoding="utf-8")
revoker = generate_ed25519()
(data / "revoker.hex").write_text(revoker.public_bytes.hex(), encoding="utf-8")
# A signed revocation for another demo ID proves the loader handles real entries.
entry = sign_revocation(
    manifest_id="019236ab-cdef-7000-8000-000000000099",
    reason="demo withdrawal", revoked_by="spiffe://example.test/security", keypair=revoker,
)
(data / "revocations.jsonl").write_text(entry.model_dump_json() + "\n", encoding="utf-8")
print("Saved demo service inputs in data/")

For a real service, context.json (what the service expects to be running) and revoker.hex (the key allowed to sign revocations) come from your own trust configuration. Never take trusted keys or expected values from an incoming manifest, since a manifest always agrees with itself. This demo context covers one known configuration, not a fleet of different agents.

Create verifier.py

Save this complete block beside data/. It reads every input before it starts serving, so a missing file, a malformed revocation entry or a bad revocation signature stops it from starting at all.

import json
import os
from pathlib import Path

from fastapi import FastAPI, HTTPException
from agent_manifest import VerificationContext, verify_manifest
from agent_manifest._revocation import SignedRevocationRecord, verify_revocation_signature
from agent_manifest._verify import RevocationRecord, RevocationStore


def create_app():
    data = Path(os.environ.get("MANIFEST_DATA_DIR", "data"))
    manifest = json.loads((data / "manifest.json").read_text(encoding="utf-8"))
    context = VerificationContext.model_validate_json(
        (data / "context.json").read_text(encoding="utf-8")
    )
    if not context.trusted_keys:
        raise ValueError("Configure trusted issuer keys before starting")
    revoker = bytes.fromhex((data / "revoker.hex").read_text(encoding="utf-8").strip())
    if len(revoker) != 32:
        raise ValueError("Expected a raw Ed25519 public key")
    revocations = RevocationStore()
    for line in (data / "revocations.jsonl").read_text(encoding="utf-8").splitlines():
        if not line.strip():
            continue
        entry = SignedRevocationRecord.model_validate_json(line)
        verify_revocation_signature(entry, revoker)
        revocations.revoke(RevocationRecord(
            manifest_id=entry.manifest_id, revoked_at=entry.revoked_at,
            reason=entry.reason, revoked_by=entry.revoked_by,
        ))

    app = FastAPI(title="Local manifest verifier")

    @app.get("/verify")
    async def verify(manifest_id: str):
        if manifest_id != manifest["manifest_id"]:
            raise HTTPException(status_code=404, detail="Unknown manifest")
        return verify_manifest(manifest, context, revocations).model_dump(mode="json")

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

    @app.get("/ready")
    async def ready():
        return {"status": "loaded"}

    return app

This small service exposes only /verify, /health, and /ready. "Ready" means its inputs loaded; it says nothing about whether a manifest is acceptable. A healthy service can still return a rejection for an expired or revoked manifest.

Run and query it

From the source checkout used in the first tutorial:

python -m pip install -e "./python[server]"
python -m uvicorn verifier:create_app --factory --host 127.0.0.1 --port 8080

In another terminal:

curl "http://127.0.0.1:8080/verify?manifest_id=019236ab-cdef-7000-8000-000000000001"
curl http://127.0.0.1:8080/ready

The fresh demo returns a JSON verdict with result: "VALID" and signature_verified: true. An unknown ID returns HTTP 404. HTTP 200 only means the check ran, and the verdict inside can still be a rejection: the caller must inspect result and reject every non-VALID outcome.

Package the same service

To run the same service in a container, save this as Dockerfile at the checkout root. Building from the same python/ source keeps the container in step with the code you just ran.

FROM python:3.12-slim
WORKDIR /app
COPY python/ /sdk/
RUN pip install --no-cache-dir "/sdk[server]"
COPY verifier.py /app/verifier.py
CMD ["python", "-m", "uvicorn", "verifier:create_app", "--factory", "--host", "0.0.0.0", "--port", "8080"]

Save compose.yaml alongside it:

services:
  verifier:
    build: .
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ./data:/data:ro
    environment:
      MANIFEST_DATA_DIR: /data
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/ready', timeout=2).close()"]
      interval: 10s
      timeout: 5s
      retries: 3

Stop the local server before starting the container on the same port:

docker compose up --build

The health check uses Python from the image. The service is published on localhost and reads its mounted inputs without writing them.

Refresh and operate it

The service reads its files once, at startup. To change what it trusts or which manifests are revoked, publish a complete new set of files and restart it.

Technical detail: reloading, revocation freshness and deployment shape

This example snapshots files at startup. File changes do not update the running worker. Validate and publish an entire new configuration snapshot, then restart every worker; docker compose restart verifier reloads the mounted files in this local example. There is no unauthenticated reload endpoint.

Signed entries establish who issued each revocation, not that the list is complete or current. An empty, truncated, or stale file needs separate detection through your authenticated distribution and freshness policy. The explicit loader above raises on invalid entries instead of using FileCRL's skip-invalid-entry behavior. See revocation and key rotation.

Before exposing a service beyond your own machine, add authenticated callers, operation authorization, request limits, transport protection, and evidence-refresh policy. A manifest ID identifies a document, not the requesting agent.

Technical detail: central service, embedded verifier or sidecar

A central service requires a network request; an embedded verifier avoids that request, while a sidecar usually uses local IPC or loopback. Choose based on trust ownership and deployment requirements rather than an assumed latency ranking.

For a protected operation in the application itself, follow server-side verification. The container configuration here does not provision a production cluster or hardware attestation.