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
maqpnaCLI (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 thatmaqpna 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 LangGraphToolNode.invoke_and_report(...)runs the graph and, when it ends, reports the session's result (Succeededwith the final answer, orFailed).- The model client is
ChatOpenAIpointed atOPENAI_BASE_URL(locally) orMAQPNA_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.endpointandspec.model.name: your OpenAI-compatible model server. Only the gateway talks to it. The sandbox getsMAQPNA_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 withmaqpna policy testin 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#
- Framework adapters: the approval modes (
error,wait,interrupt) and the other frameworks. - Handle decisions in your agent: denials, approvals and retries in your own code.
- Policy as code in CI: keep
toolpolicy.yamlin Git and test it on every pull request. - Two agents over A2A: let a planner delegate work to this kind of agent.
- Write policies for the full rule syntax.