MAQPNADocs

Anatomy of an agent project

The code, image and manifests that make up a governed agent, how the MAQPNA resources relate, how to scaffold them with maqpna init, and a complete minimal set that validates.

A governed agent is two things: your code, packaged as a container image, and a few Kubernetes resources that tell MAQPNA how to run it: which trust tier, which MCP servers, which policy and which budget. This page shows what goes in each file, how the resources point at each other, and a minimal set you can copy and check with maqpna validate.

The resources#

Kind Plain name Scope You write it when What it says
Agent agent namespace Always Image, trust tier, model, MCP servers (tools), scopes, budget, timeouts.
ToolPolicy policy namespace Always (no policy means every call is denied) Which agents it applies to, a default action and an ordered list of rules.
MCPServer MCP server namespace The agent uses a tool server URL, transport, trust, data class, and the credential the gateway injects.
TrustTier trust tier cluster Usually written by the platform team Isolation (gvisor, microVM, confidential VM), runtime class, egress, jurisdiction.
BudgetPolicy budget cluster You want namespace or user spend limits Per-day and per-month limits and what happens when they run out.
AgentSession session namespace You start a run declaratively (or use maqpna session start) The agent, the user it acts for, scopes, TTL and the task.
A2APeer A2A peer namespace The agent calls a remote agent The peer URL, pinned card keys and allowed callers.

Per-agent spend limits are not a separate kind: they go in Agent.spec.budget.

How they relate#

erDiagram
    TrustTier ||--o{ Agent : "spec.tier"
    Agent ||--o{ AgentSession : "spec.agentRef"
    Agent }o--o{ MCPServer : "spec.tools[].serverRef"
    ToolPolicy }o--o{ Agent : "spec.agents (globs)"
    Agent }o--o| ToolPolicy : "spec.policyRef (checked)"
    BudgetPolicy }o--o{ Agent : "namespaces, users"
    A2APeer }o--o{ Agent : "spec.allowedCallers"
    AgentSession ||--|| Sandbox : "operator creates"

Three rules explain most surprises:

  1. Policies select agents, not the other way round. A ToolPolicy applies to every agent in its namespace whose name matches spec.agents (empty means all agents in the namespace). Agent.spec.policyRef only lets the operator check that the named policy exists (condition PolicyFound). Every applicable policy is evaluated and the strictest decision wins.
  2. Scopes come from tools. Without spec.scopes, the agent's sessions get tools:<name> for each entry in spec.tools. A session may ask for fewer scopes, never more. A model route scope (models:<namespace>.<agent>) is added automatically when spec.model.endpoint is set.
  3. The tool name is the URL path. tools: [{name: github}] gives the sandbox MAQPNA_TOOL_GITHUB_URL={gateway}/mcp/github, and policy rules match it as servers: [github].

Step 1: Lay out the project#

A layout that keeps code, manifests, policies and tests apart works with every command on these pages:

triage-agent/
  agent.py                  # or agent.mjs, main.go
  requirements.txt
  Dockerfile
  manifests/
    agent.yaml              # Agent
    mcpserver.yaml          # MCPServer
    budget.yaml             # BudgetPolicy (optional)
    tier.yaml               # TrustTier (only if your team owns tiers)
  policies/
    triage-policy.yaml      # ToolPolicy, one or more per file
  policy-tests/
    maqpna-test.yaml        # PolicyTest suite for maqpna policy test
  local/
    policies.json           # policy bundle for maqpna dev up --policy (optional)
  .gitignore                # include .maqpna/

Keep policies in their own directory. maqpna policy lint and maqpna policy test --policy DIR read only ToolPolicy resources and report every other kind in the directory as an error.

Step 2: Scaffold the manifests#

maqpna init prints a starter manifest from a built-in sample; there is no code scaffolding command. List the samples:

maqpna init --list
KIND                  SAMPLE
A2APeer               a2apeer-partner
Agent                 agent-browser
Agent                 agent-code-interpreter
Agent                 agent-coder
...
MCPServer             mcpserver-github
ToolPolicy            toolpolicy-coder
ToolPolicy            toolpolicy-v2-guardrails
TrustTier             tier-0-gvisor

Write one per file, then edit it:

maqpna init agent triage --namespace team-a > manifests/agent.yaml
maqpna init mcpserver github --namespace team-a --from-sample mcpserver-github > manifests/mcpserver.yaml
maqpna init toolpolicy triage-policy --namespace team-a --from-sample toolpolicy-coder > policies/triage-policy.yaml

To see what a field means without a cluster, use maqpna explain; the schemas are the CRDs the CLI was built with:

maqpna explain agent.spec.tools
maqpna explain toolpolicy.spec.rules --recursive

For code, start from an example in the source tree (examples-agents/ for LangGraph, the OpenAI Agents SDK, the Claude Agent SDK and a plain SDK agent) or a framework template.

Step 3: Write a minimal set#

This set is one agent, triage, that reads GitHub issues through an MCP server, comments only with approval, and has a spend limit. It passes maqpna validate --strict as written.

manifests/agent.yaml:

apiVersion: maqpna.com/v1alpha1
kind: Agent
metadata:
  name: triage
  namespace: team-a
spec:
  image: registry.example.eu/agents/triage:0.1.0
  tier: tier-0-gvisor
  model:
    provider: vllm
    endpoint: http://vllm.models.svc:8000/v1
    name: qwen3-coder-30b
    maxTokensPerRequest: 4096
  tools:
  - name: github
    serverRef: {name: github}
  policyRef: triage-policy
  budget:
    perSessionUSD: "2"
    perDayUSD: "50"
  maxSessionTTL: 2h

manifests/mcpserver.yaml:

apiVersion: maqpna.com/v1alpha1
kind: MCPServer
metadata:
  name: github
  namespace: team-a
spec:
  url: https://mcp-github.tools.example.eu/mcp
  transport: streamable-http
  trust: trusted
  auth:
    type: bearerSecret
    secretRef:
      name: github-mcp
      key: token

policies/triage-policy.yaml:

apiVersion: maqpna.com/v1alpha1
kind: ToolPolicy
metadata:
  name: triage-policy
  namespace: team-a
spec:
  agents: ["triage"]
  defaultAction: deny
  rules:
  - name: github-read
    servers: [github]
    tools: ["get_*", "list_*", "search_*"]
    action: allow
  - name: comment-needs-approval
    servers: [github]
    tools: ["add_issue_comment"]
    action: require_approval
    reason: Public comments need a human.

manifests/tier.yaml (skip it if the tier exists in your cluster):

apiVersion: maqpna.com/v1alpha1
kind: TrustTier
metadata:
  name: tier-0-gvisor
spec:
  runtimeClassName: gvisor
  isolation: gvisor
  egress: gateway-only

manifests/budget.yaml (cluster-scoped, usually owned by the platform team):

apiVersion: maqpna.com/v1alpha1
kind: BudgetPolicy
metadata:
  name: team-a-budget
spec:
  namespaces:
    team-a: {perDayUSD: "200", perMonthUSD: "3000"}
  onExceed: deny

What each choice does:

  • model.endpoint is the real model URL. Only the gateway talks to it; the sandbox gets MAQPNA_MODEL_ENDPOINT={gateway}/llm/team-a.triage/v1.
  • The token for github-mcp stays in a Secret the gateway reads. The sandbox never sees it.
  • defaultAction: deny means a tool the rules do not name is denied, for example delete_repository.

Step 4: Validate#

maqpna validate -f manifests/ -f policies/ --strict
5 file(s), 5 object(s): 0 error(s), 0 warning(s)

maqpna validate checks every document against the CRD schemas and CEL rules, compiles every ToolPolicy the way the operator renders it, and resolves spec.tier, spec.policyRef and tools[].serverRef within the files. A reference to an object that is not in the files is informational (it must exist in the cluster), or a warning with --strict. A typo in a field is an error:

SEVERITY  FILE              OBJECT               FIELD       CODE           MESSAGE
ERROR     bad/agent.yaml:1  Agent/team-a/triage  spec.tierr  unknown-field  unknown field (the API server drops it)

Exit status is 0 when valid and 3 on errors, so it works in CI as is.

Step 5: Mirror the policy for the local stack#

The local gateway reads a policy bundle in JSON, not ToolPolicy YAML. To run your agent locally under your own rules, write the same rules as a bundle and start the stack with it. The local maqpna dev run defaults are agent coder in namespace dev, so either use those names in the bundle or pass --agent triage --namespace team-a to maqpna dev run.

{
  "policies": [
    {
      "name": "triage-policy",
      "namespace": "team-a",
      "agents": ["triage"],
      "defaultAction": "deny",
      "rules": [
        {"name": "github-read", "servers": ["github"], "tools": ["get_*", "list_*", "search_*"], "action": "allow"},
        {"name": "comment-needs-approval", "servers": ["github"], "tools": ["add_issue_comment"],
         "action": "require_approval", "reason": "Public comments need a human."}
      ]
    }
  ]
}
maqpna dev up --policy local/policies.json --upstream github=http://127.0.0.1:9000/mcp
maqpna dev run --agent triage --namespace team-a -- python agent.py

The gateway re-reads .maqpna/policies.json every 2 seconds, so you can edit it while the stack runs. maqpna policy test --policy local/policies.json ... accepts the bundle too, so you can check that both files decide the same way.

Next steps#