MAQPNADocs

Build a governed LangGraph agent

Start from the MAQPNA LangGraph template, give it its own policy, watch the gateway allow, deny and hold its tool calls on your laptop, then deploy the same code to a cluster.

In this tutorial you build an operations agent with LangGraph. You start from the LangGraph template that ships with MAQPNA, run it on your laptop against a local MAQPNA and a scripted model, write a policy for it, and see each tool call allowed, denied or held for a human approval. At the end you package the same code as an image and deploy it to a cluster with an Agent, a ToolPolicy and an AgentSession.

What you'll build#

flowchart LR
    subgraph laptop["Your laptop"]
        A["agent.py<br/>LangGraph graph"] -->|"model calls"| GW["MAQPNA gateway<br/>:8080"]
        A -->|"tool calls<br/>(MAQPNA ToolNode)"| GW
        GW -->|"/llm/stub"| M["stub-llm<br/>scripted model"]
        GW -->|"/mcp/echo"| E["mcp-echo<br/>test tools"]
        GW --> L[("audit ledger<br/>.maqpna/audit.jsonl")]
        P["policies.json<br/>your policy"] -.-> GW
    end
    H["you, as approver<br/>maqpna approvals"] -->|approve| GW

The graph has two nodes, model and tools. The tools node is the MAQPNA adapter's ToolNode, so every tool call passes through the MAQPNA gateway and comes back as something LangGraph understands: a result, a tool error the model can react to, or an interrupt() while a human decides.

Time About 30 minutes (plus 10 for the cluster part)
Level Intermediate: you know some Python and have seen LangGraph
You learn The LangGraph adapter, local policies, approvals as interrupt(), testing a policy offline, deploying with manifests

Prerequisites#

  • The maqpna CLI (see Install) and free ports 8080, 8081, 8090 and 8000.
  • Python 3.10 or later. The template's pins were tested with Python 3.12.
  • A checkout of the MAQPNA source (from maqpna.com/github). The template and the Python SDK live there. Set its path once:
export MAQPNA_SRC=~/src/maqpna     # where you cloned the source
  • For the cluster part only: Docker (or another image builder), a registry you can push to, and an installation with the chart's test MCP server enabled (examples.mcpEcho.enabled=true). See Production install with Helm.

No API key and no GPU are needed. The model is stub-llm, a scripted OpenAI-compatible model that maqpna dev up --stub-llm starts behind the gateway.

Step 1: Create the project from the template#

Make an empty folder and copy the template's agent and its dependency pins into it:

mkdir ops-agent && cd ops-agent
cp "$MAQPNA_SRC/examples-agents/langgraph/agent.py" .
cp "$MAQPNA_SRC/examples-agents/langgraph/requirements.txt" .
cp -r "$MAQPNA_SRC/examples-agents/langgraph/manifests" .

Create a virtual environment and install the MAQPNA Python SDK with the template's pins:

python3 -m venv .venv && . .venv/bin/activate
pip install kosa8-maqpna -r requirements.txt

requirements.txt pins langgraph==1.2.12, langchain-core==1.6.6, langchain-openai==1.6.7 and httpx==0.28.1.

Open agent.py. The parts that matter for MAQPNA are short:

from maqpna import MaqpnaClient
from maqpna.integrations.langgraph import INTERRUPT_TYPE, final_text, invoke_and_report, tool_node

servers = [client.tools(name) for name in client.available_tool_servers()]
tools = tool_node(servers)  # the MAQPNA adapter: governed tools, interrupt() for approvals
  • MaqpnaClient.from_env() reads the gateway URL, session token and tool server URLs that maqpna dev run (or a sandbox) puts in the environment.
  • tool_node(servers) lists the tools of each MCP server through the gateway and wraps them in a LangGraph ToolNode.
  • invoke_and_report(...) runs the graph and, when it ends, reports the session's result (Succeeded with the final answer, or Failed).
  • The model client is ChatOpenAI pointed at OPENAI_BASE_URL (locally) or MAQPNA_MODEL_ENDPOINT (in a sandbox), with the session token as the API key. Model calls are governed too.

Check it works#

python -c "import maqpna.integrations.langgraph, langgraph; print('ok')"

It prints ok. If the import fails, re-run the pip install line inside the activated virtual environment.

Step 2: Script the model#

The stub model answers from a script: the step it plays is the number of assistant messages already in the conversation. Write a script that makes the agent try four tool calls, one per turn, then finish:

# script.yaml
model: stub
steps:
  - tool_calls:
      - name: get_time
  - tool_calls:
      - name: echo
        arguments: {text: "cluster looks healthy"}
  - tool_calls:
      - name: delete_resource
        arguments: {id: "coredns", namespace: "kube-system"}
  - tool_calls:
      - name: delete_resource
        arguments: {id: "cache-7", namespace: "staging"}
  - content: "Checked the time, reported health, and asked to clean up cache-7. The kube-system delete was refused."

The tools get_time, echo and delete_resource come from mcp-echo, the test MCP server that maqpna dev up starts. delete_resource only pretends to delete.

Step 3: Write the agent's policy#

The local gateway reads policies as bundle JSON from .maqpna/policies.json. maqpna dev up --policy FILE copies your file there. --policy takes bundle JSON, not ToolPolicy YAML.

Write a policy for the agent langgraph in the local namespace dev. It mirrors manifests/toolpolicy.yaml, which you deploy later:

{
  "policies": [
    {
      "name": "langgraph-guard",
      "namespace": "dev",
      "agents": ["langgraph"],
      "defaultAction": "deny",
      "rules": [
        {
          "name": "no-system-namespaces",
          "servers": ["echo"],
          "tools": ["*"],
          "argMatch": {"namespace": "kube-*"},
          "action": "deny",
          "reason": "system namespaces are off-limits to agents"
        },
        {
          "name": "read-only",
          "servers": ["echo"],
          "tools": ["echo", "get_time"],
          "action": "allow",
          "maxCallsPerMinute": 60
        },
        {
          "name": "deletes-need-a-human",
          "servers": ["echo"],
          "tools": ["delete_*"],
          "action": "require_approval",
          "reason": "destructive operation"
        }
      ]
    }
  ]
}

Save it as policies.json. Rules are checked in order and the first match decides. Anything no rule matches is denied (defaultAction: deny).

Check it works#

Test the policy offline, before any agent runs. maqpna policy test accepts both bundle JSON and ToolPolicy YAML:

maqpna policy test --policy policies.json --ns dev --agent langgraph \
  --server echo --tool delete_resource --args '{"namespace":"kube-system"}' --expect deny
maqpna policy test --policy policies.json --ns dev --agent langgraph \
  --server echo --tool delete_resource --args '{"namespace":"staging"}' --expect require_approval
maqpna policy test --policy manifests/toolpolicy.yaml --ns maqpna-agents --agent langgraph \
  --server echo --tool delete_resource --args '{"namespace":"staging"}' --expect require_approval

Each command prints the decision as JSON (action, policy, rule, reason) and exits with status 0. A wrong --expect exits with status 3. The last command shows that the YAML you deploy later decides the same way.

Step 4: Start a local MAQPNA with your policy and script#

maqpna dev up --stub-llm --stub-llm-script script.yaml --policy policies.json

This starts the identity broker (port 8081), the gateway (8080), mcp-echo (8090) and stub-llm (8000) on 127.0.0.1, with state in ./.maqpna. The gateway serves the stub model as model route stub. See maqpna dev up for every flag.

Check it works#

maqpna dev status

maqpna-gateway, maqpna-identity, mcp-echo and stub-llm are running. If a port is taken, see Troubleshooting.

Step 5: Run the agent#

Run the agent as agent langgraph, so your policy applies. Set MAQPNA_WAIT_FOR_APPROVALS=true so a held call waits for your decision instead of being skipped:

MAQPNA_WAIT_FOR_APPROVALS=true \
  maqpna dev run --agent langgraph --session ops-1 -- python agent.py "check the cluster and clean up"

maqpna dev run mints a session token for session ops-1 (agent langgraph, namespace dev, on behalf of you@localhost) and runs the command with MAQPNA_GATEWAY_URL, MAQPNA_TOKEN, MAQPNA_TOOL_ECHO_URL, OPENAI_BASE_URL, OPENAI_API_KEY and MAQPNA_MODEL_NAME=stub set.

The agent logs to stderr with a [langgraph] prefix. It lists the tools it can see (delete_resource, echo, get_time), makes the get_time and echo calls, and gets the kube-system delete back as a tool error:

denied by policy: denied by policy langgraph-guard rule no-system-namespaces: system namespaces are off-limits to agents rule=no-system-namespaces reason=policy_denied

The model sees that message as the tool's answer and moves on. Then the graph reaches the staging delete. The gateway holds it, the adapter raises interrupt(), and the agent prints a line like:

[langgraph] PENDING echo.delete_resource approval=apr_...

Leave it waiting and go to the next step.

Step 6: Approve the held call#

In a second terminal, in the same ops-agent folder, load the local admin token and list what is pending:

eval "$(maqpna dev env)"
maqpna approvals list
ID                           STATUS    NAMESPACE      AGENT          TOOL                     REASON
apr_...                      pending   dev            langgraph      echo/delete_resource     destructive operation

Approve it as someone other than the user the agent acts for. Approvers cannot approve calls made on their own behalf:

maqpna approvals approve apr_... --approver reviewer@localhost --note "cache-7 is a scratch cache"

Back in the first terminal, the graph resumes, the delete runs, and the agent prints the tool result (deleted resource cache-7 in namespace staging (simulated)) and then the final answer from the script. maqpna dev run ends with a summary line for session ops-1.

To see the other path, run step 5 again with a new --session and deny it with maqpna approvals deny ID --approver reviewer@localhost --note "...". The agent then gets a tool error with reason approval_denied. You can also decide in MAQPNA Desk (maqpna desk).

Check it works#

maqpna dev timeline --session ops-1

The timeline lists, in ledger order:

Kind Server/tool Decision Why
model_call llm/stub/chat.completions allow One per graph turn; token usage is metered
tool_call echo/get_time allow Rule read-only
tool_call echo/echo allow Rule read-only
tool_call echo/delete_resource deny Rule no-system-namespaces
tool_call echo/delete_resource held, then allowed Rule deletes-need-a-human, approved by reviewer@localhost
result maqpna-session/report_result observe session_result:Succeeded, from invoke_and_report

Add -o json for every field. Then prove the ledger is intact:

maqpna audit verify .maqpna/audit.jsonl

It prints OK: N records, hash chain intact.

Step 7: Change the policy while the stack runs#

The local gateway re-reads .maqpna/policies.json every 2 seconds. Tighten the policy without restarting: make every delete a denial.

python3 - <<'EOF'
import json
p = json.load(open(".maqpna/policies.json"))
rule = next(r for r in p["policies"][0]["rules"] if r["name"] == "deletes-need-a-human")
rule["action"], rule["reason"] = "deny", "deletes are frozen this week"
json.dump(p, open(".maqpna/policies.json", "w"), indent=2)
EOF

Run the agent again (maqpna dev run --agent langgraph --session ops-2 -- python agent.py "clean up"). The staging delete is now denied straight away with deletes are frozen this week, and nothing waits for approval.

Check it works#

maqpna dev timeline --session ops-2 shows two deny rows for echo/delete_resource. If the old behaviour stays, the file is not valid bundle JSON and the gateway kept the last good policy: check .maqpna/logs/maqpna-gateway.log.

Put the policy back for the next step: cp policies.json .maqpna/policies.json.

Step 8: Package the agent#

The template's Dockerfile builds from the root of the source tree. For your own folder, vendor the SDK and adapt it:

cp -r "$MAQPNA_SRC/sdk/python" vendor-sdk
# Dockerfile
FROM python:3.12-slim AS build
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
RUN python -m venv /opt/venv
COPY vendor-sdk /src/sdk-python
COPY requirements.txt /src/requirements.txt
RUN /opt/venv/bin/pip install /src/sdk-python -r /src/requirements.txt
COPY agent.py /app/agent.py
RUN /opt/venv/bin/python -m compileall -q /opt/venv /app

FROM python:3.12-slim
ENV PATH=/opt/venv/bin:$PATH PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 HOME=/tmp TMPDIR=/tmp
COPY --from=build /opt/venv /opt/venv
COPY --from=build /app /app
USER 65532:65532
WORKDIR /app
ENTRYPOINT ["python", "/app/agent.py"]

It runs as UID 65532 and writes only to /tmp, so it works under the sandbox's read-only root filesystem. Build and push:

docker build -t registry.example.eu/agents/ops-agent:0.1.0 .
docker push registry.example.eu/agents/ops-agent:0.1.0

Step 9: Deploy to a cluster#

manifests/ holds three resources in namespace maqpna-agents:

File Resource What it says
agent.yaml Agent langgraph Image, trust tier tier-0 (gVisor), model route, tool server echo, policyRef: langgraph-guard
toolpolicy.yaml ToolPolicy langgraph-guard The same three rules as your policies.json
agentsession.yaml AgentSession langgraph-demo One run for [email protected] with a task and ttl: 30m

Edit manifests/agent.yaml:

  • spec.image: your image, registry.example.eu/agents/ops-agent:0.1.0. Pin a digest if your sovereignty policy requires one.
  • spec.model.endpoint and spec.model.name: your OpenAI-compatible model server. Only the gateway talks to it. The sandbox gets MAQPNA_MODEL_ENDPOINT, the gateway's model route for this agent, and uses its session token as the API key.

Validate offline, then apply:

maqpna validate -f manifests/
maqpna apply -f manifests/

maqpna validate reports 0 error(s). An INFO line about TrustTier "tier-0" is expected: the chart creates that tier, not your files.

Check it works#

kubectl -n maqpna-agents get agentsession langgraph-demo -o jsonpath='{.status.outcome}'

When the run ends it prints Succeeded (what invoke_and_report reported). In the cluster MAQPNA_WAIT_FOR_APPROVALS is not set, so a held delete is skipped rather than waited for; approve it with maqpna approvals list -n maqpna-agents and start a new session, or set MAQPNA_WAIT_FOR_APPROVALS=true in Agent.spec.env. Use maqpna session start to start more runs without writing YAML (see Sessions).

What you built#

  • A LangGraph agent whose every tool call and model call goes through the MAQPNA gateway, copied from the real template.
  • A policy that allows read-only tools, denies anything in kube-* and holds deletes for a human, tested offline with maqpna policy test in both JSON and YAML form.
  • An approval that paused the graph with interrupt() and resumed it after a human decision.
  • A session timeline and a verified, hash-chained audit ledger of every decision.
  • An image and three manifests that run the same code in a gVisor sandbox in a cluster.

Clean up#

maqpna dev down                          # stops the local MAQPNA; .maqpna/ is kept
rm -rf .maqpna                           # optional: delete local keys, policy and ledger
kubectl delete -f manifests/             # if you deployed to a cluster

Troubleshooting#

Symptom Cause Fix
no MAQPNA_TOOL_<NAME>_URL in the environment The agent ran outside maqpna dev run or a sandbox. Run it under maqpna dev run --.
Every tool call is denied with reason no_applicable_policy The agent name does not match your policy's agents. Pass --agent langgraph to maqpna dev run.
The model call fails with a connection error maqpna dev up ran without --stub-llm, so OPENAI_BASE_URL is not set. maqpna dev down, then step 4 again.
The run goes on without the held call MAQPNA_WAIT_FOR_APPROVALS is not true. Set it as in step 5.
The held call ends with approval_expired Nobody decided within five minutes (the local gateway's approval timeout). Run again and decide sooner.
✗ port 8080 is in use Another process or local MAQPNA uses the port. maqpna dev down in the other folder, or pass --gateway-port 18080 --identity-port 18081 --echo-port 18090.

Next steps#