Python for Network Engineers — Part 12: AI-Assisted Network Automation
Eleven posts in, you can write automation that collects structured state from a multi-vendor fleet, renders Jinja2 configs from YAML data, validates data with Pydantic, runs tasks in parallel with Nornir, and deploys configs with NAPALM’s diff-and-commit workflow. That’s a complete automation stack.
This final post is about where AI fits into that stack — and equally, where it doesn’t.
AI tools have become genuinely useful in network automation over the past two years. Not as magic replacements for the skills you’ve built, but as accelerators for specific tasks: parsing unfamiliar output, reviewing configuration for problems, generating boilerplate you’d otherwise write manually, and increasingly, as autonomous agents that can call your automation scripts in response to natural language requests.
Used well, AI makes the workflows above faster and easier. Used carelessly, it introduces errors that are subtle and hard to catch — exactly the kind of errors that cause outages in production networks. The discipline matters as much as the capability.
How AI Fits Into What We’ve Built
The series has touched on AI at several points:
- Part 1: Using AI as a pair programmer for writing and explaining code
- Part 4: AI-assisted regex generation and explanation
- Part 6: AI-generated parsers for commands with no community template
Part 12 goes further: AI as an active participant in automation workflows, not just a code assistant.
There are four distinct use cases worth examining:
- Config review — AI reads a configuration and flags problems
- Fleet-wide parsing — AI parses CLI output that TextFSM doesn’t cover, at scale
- Autonomous operation — AI agents that call your automation scripts directly
- Code generation — AI writes the automation code itself
Use Case 1: AI Config Review
Config review is one of the highest-value AI applications in networking. You’ve got a rendered Jinja2 config about to go to a production device. A second pair of eyes that knows the platform’s best practices would be valuable. AI can do this at zero marginal cost per review.
Here’s a practical implementation using the Anthropic API:
uv pip install anthropic
"""AI-powered config review using Claude API."""
import anthropic
REVIEW_PROMPT = """You are a senior network engineer reviewing a network device configuration before deployment.
Review the following {platform} configuration and identify:
1. Security issues (weak creds, unnecessary services, missing ACLs, etc.)
2. Missing best practices for this platform
3. Any misconfigurations or suspicious settings
4. Questions that should be answered before deploying this config
Be specific. Reference line numbers or specific config stanzas where possible.
Flag severity: CRITICAL / HIGH / MEDIUM / LOW.
Configuration to review:
{config}
def review_config(config: str, platform: str = "Arista EOS") -> str:
"""Submit a config for AI review. Returns the review as a string."""
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from env
message = client.messages.create(
model="claude-opus-4-8",
max_tokens=2048,
messages=[
{
"role": "user",
"content": REVIEW_PROMPT.format(platform=platform, config=config),
}
],
)
return message.content[0].text
Use it in the Nornir deployment workflow from Part 11 — review before committing:
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
import napalm
env = Environment(loader=FileSystemLoader("templates"), trim_blocks=True, lstrip_blocks=True)
def render_review_deploy(task):
"""Render config, AI review, diff, optionally commit."""
# 1. Render config
template = env.get_template(f"{task.host.platform}_config.j2")
rendered = template.render(device=task.host.data)
# 2. AI review
print(f"\n{'='*60}")
print(f"AI Review for {task.host.name}")
print('='*60)
review = review_config(rendered, platform="Arista EOS")
print(review)
# 3. Show diff
driver = napalm.get_network_driver("eos")
with driver(
hostname=task.host.hostname,
username=task.host.username,
password=task.host.password,
) as device:
device.load_replace_candidate(config=rendered)
diff = device.compare_config()
if diff:
print(f"\n{'─'*60}")
print(f"Config diff for {task.host.name}:")
print(diff)
confirm = input(f"\nDeploy to {task.host.name}? [y/N]: ")
if confirm.lower() == "y":
device.commit_config()
return Result(host=task.host, changed=True, result="deployed")
else:
device.discard_config()
return Result(host=task.host, changed=False, result="skipped")
else:
device.discard_config()
return Result(host=task.host, changed=False, result="no_changes")
What AI Review Is Good At
- Spotting missing
no service password-encryption/service password-encryptionwhere unexpected - Catching telnet enabled alongside SSH
- Flagging permissive ACLs (
permit ip any any) that crept in during testing - Noting missing SNMP community string restrictions
- Flagging NTP servers that are public but the device is in a private network
- Noting management-plane best practices specific to the platform
What It’s Not Good At
- Business intent. AI doesn’t know that VLAN 99 is your management VLAN that should always be present.
- Your organisation’s standards. It doesn’t know your naming convention.
- Current network state. It can’t tell you whether a BGP neighbour address you’ve configured actually has a device behind it.
Use AI review as a general best-practices check. Build separate validation that checks your organisation-specific rules in code — Pydantic models, explicit assertions, or diff comparison against a golden config.
Use Case 2: Fleet-Wide AI-Assisted Parsing
Part 6 covered AI-assisted parsing for individual commands. At scale, this becomes a structured workflow: collect output from the fleet, send it to an AI for parsing, aggregate the structured results.
The key is batching efficiently. Sending one API call per device per command is expensive and slow. Instead, collect all output first, then process:
import anthropic
import json
PARSE_PROMPT = """Parse the following {platform} '{command}' output into a JSON object with these fields:
{fields}
Return ONLY valid JSON. No explanation, no markdown code blocks. Raw JSON only.
Output to parse:
{output}"""
def batch_parse_output(
outputs: dict[str, str],
command: str,
platform: str,
fields: dict[str, str],
) -> dict[str, dict]:
"""Parse CLI output from multiple devices using AI.
Args:
outputs: {hostname: raw_output}
command: The CLI command that produced the output
platform: Platform string (e.g. "Fortinet FortiOS")
fields: {field_name: description} to extract
Returns:
{hostname: parsed_dict}
"""
client = anthropic.Anthropic()
fields_desc = "\n".join(f"- {k}: {v}" for k, v in fields.items())
results = {}
for hostname, raw_output in outputs.items():
prompt = PARSE_PROMPT.format(
platform=platform,
command=command,
fields=fields_desc,
output=raw_output,
)
message = client.messages.create(
model="claude-haiku-4-5-20251001", # cheaper for structured extraction
max_tokens=512,
messages=[{"role": "user", "content": prompt}],
)
try:
results[hostname] = json.loads(message.content[0].text)
except json.JSONDecodeError:
results[hostname] = {"parse_error": message.content[0].text[:200]}
return results
Usage — collect FortiGate performance status from a fleet and parse it:
from netmiko import ConnectHandler
# Collect raw output
raw_outputs = {}
for hostname, conn_params in fortinet_devices.items():
with ConnectHandler(**conn_params) as conn:
raw_outputs[hostname] = conn.send_command("get system performance status")
# AI parse in batch
parsed = batch_parse_output(
outputs=raw_outputs,
command="get system performance status",
platform="Fortinet FortiOS",
fields={
"cpu_user_pct": "CPU user percentage as integer",
"cpu_idle_pct": "CPU idle percentage as integer",
"memory_used_pct": "Memory used percentage as float",
"avg_sessions_1min": "Average sessions in last 1 minute as integer",
"uptime_days": "Uptime in days as integer",
},
)
# Report
print(f"\n{'Device':<20} {'CPU%':<8} {'Mem%':<8} {'Sessions':<12} {'Uptime'}")
print("─" * 65)
for hostname, data in parsed.items():
if "parse_error" not in data:
print(
f"{hostname:<20} "
f"{data.get('cpu_user_pct', '—'):<8} "
f"{data.get('memory_used_pct', '—'):<8} "
f"{data.get('avg_sessions_1min', '—'):<12} "
f"{data.get('uptime_days', '—')}d"
)
Cost note: claude-haiku-4-5-20251001 is the right model for structured extraction — it’s fast and cheap. Use Claude Opus for complex reasoning tasks (config review, root cause analysis). Use Haiku for pattern matching and structured parsing. The cost difference is significant at scale.
Validation After AI Parsing
Always validate AI-parsed output before acting on it:
from pydantic import BaseModel, field_validator
from typing import Optional
class FortiGatePerfStatus(BaseModel):
cpu_user_pct: Optional[int] = None
cpu_idle_pct: Optional[int] = None
memory_used_pct: Optional[float] = None
avg_sessions_1min: Optional[int] = None
uptime_days: Optional[int] = None
@field_validator("cpu_user_pct", "cpu_idle_pct")
@classmethod
def valid_pct(cls, v):
if v is not None and not (0 <= v <= 100):
raise ValueError(f"percentage {v} out of range")
return v
for hostname, raw_data in parsed.items():
try:
validated = FortiGatePerfStatus(**raw_data)
# Use validated data
except Exception as e:
print(f"{hostname}: validation failed — {e}")
The Pydantic model catches cases where the AI returned a string like "47.9%" instead of 47.9, or None where an integer was expected. Don’t skip validation on AI output — it’s exactly as important as validating any other external data source.
Use Case 3: MCP Servers — AI Agents That Call Your Scripts
The AI use cases above are one-directional: your Python code calls an AI API. The more powerful pattern runs in the other direction: an AI agent calls your automation scripts.
Model Context Protocol (MCP) is an open standard from Anthropic that lets AI assistants call tools you define. You write a small MCP server that exposes your automation functions as tools, and an AI agent like Claude can call them in response to natural language requests.
This is how michealgarner.co.uk — the site hosting this post — is managed. An MCP server on the host machine exposes tools for writing files, deploying content, and rebuilding the site. Claude calls those tools via MCP to publish posts without needing direct SSH access.
Here’s a minimal MCP server that exposes your Nornir collector as a tool:
uv pip install fastmcp
"""Minimal MCP server exposing network automation tools."""
import json
from fastmcp import FastMCP
from inventory_loader import load_inventory
import napalm
mcp = FastMCP("network-automation")
@mcp.tool()
def get_device_facts(hostname: str) -> str:
"""Get basic facts about a network device.
Args:
hostname: The device name from inventory (e.g. 'spine-01', 'leaf-01')
Returns:
JSON string with vendor, model, os_version, uptime, and interface count.
"""
devices = load_inventory("hosts.yaml")
device = next((d for d in devices if d.name == hostname), None)
if not device:
return json.dumps({"error": f"Device '{hostname}' not found in inventory"})
try:
driver = napalm.get_network_driver(device.device_type)
with driver(
hostname=device.host,
username=device.username,
password=device.password,
) as conn:
facts = conn.get_facts()
return json.dumps(facts, indent=2)
except Exception as e:
return json.dumps({"error": str(e)})
@mcp.tool()
def list_inventory() -> str:
"""List all devices in the network inventory.
Returns:
JSON list of device names, IPs, roles, and platforms.
"""
devices = load_inventory("hosts.yaml")
return json.dumps([
{
"name": d.name,
"host": d.host,
"platform": d.device_type,
"role": d.role,
}
for d in devices
], indent=2)
@mcp.tool()
def get_bgp_state(hostname: str) -> str:
"""Get BGP neighbour state from a device.
Args:
hostname: The device name from inventory.
Returns:
JSON with BGP neighbours, their AS numbers, and up/down state.
"""
devices = load_inventory("hosts.yaml")
device = next((d for d in devices if d.name == hostname), None)
if not device:
return json.dumps({"error": f"Device '{hostname}' not found"})
try:
driver = napalm.get_network_driver(device.device_type)
with driver(
hostname=device.host,
username=device.username,
password=device.password,
) as conn:
bgp = conn.get_bgp_neighbors()
return json.dumps(bgp, indent=2)
except Exception as e:
return json.dumps({"error": str(e)})
if __name__ == "__main__":
mcp.run()
With this server running, an AI assistant connected to it via MCP can answer questions like:
“Is leaf-01’s BGP session to spine-01 up?” → calls
get_bgp_state("leaf-01"), reads the result, reports back
“What version of EOS is spine-01 running?” → calls
get_device_facts("spine-01"), extractsos_version
“Which devices are in my inventory?” → calls
list_inventory()
The AI does the reasoning about what to call and how to interpret the result. Your MCP tools do the actual network interaction. Neither needs to know how the other works.
MCP Safety Boundaries
The critical design decision with MCP tools is what you expose vs. what you don’t.
Safe to expose as MCP tools:
- Read-only getters (
get_facts,get_bgp_neighbors,get_interfaces) - Diagnostic commands that don’t change state
- Config review and diff generation (without commit)
- Inventory listing
Require explicit confirmation (not safe for autonomous execution):
- Config deploy with commit
- Device restart or reload
- Anything that changes routing or forwarding state
- Anything that affects more than one device in a chain
The pattern for deploy tools: generate the diff and return it, but require a separate confirm_deploy call with an explicit approval token. This forces a human in the loop for any state-changing operation:
import secrets
# In-memory store of pending deploys (use a proper store in production)
_pending_deploys: dict[str, dict] = {}
@mcp.tool()
def prepare_deploy(hostname: str) -> str:
"""Generate config diff for a device. Returns a token to confirm or cancel.
DOES NOT deploy. Returns a diff and a confirmation token.
The deploy must be explicitly confirmed using confirm_deploy().
"""
# ... generate diff ...
token = secrets.token_hex(8)
_pending_deploys[token] = {"hostname": hostname, "diff": diff, "config": rendered}
return json.dumps({"diff": diff, "confirm_token": token, "cancel_token": token})
@mcp.tool()
def confirm_deploy(token: str) -> str:
"""Execute a previously prepared deployment. Requires explicit confirmation token.
Args:
token: The token returned by prepare_deploy().
"""
deploy = _pending_deploys.pop(token, None)
if not deploy:
return json.dumps({"error": "Invalid or expired token"})
# ... commit the config ...
return json.dumps({"deployed": deploy["hostname"], "status": "ok"})
The two-step pattern means an AI can never autonomously push a config change. It can prepare and show you the diff; you confirm by passing the token back. The AI acts as an accelerator and interface, not an autonomous decision-maker.
Use Case 4: AI-Assisted Code Generation
The most common AI use in network automation is writing code — from full scripts to individual functions to test cases. This was the theme of Part 1 and Part 4, but it’s worth revisiting at the end of the series with the full context of what you’ve built.
You now know enough Python and enough network automation to evaluate AI-generated code properly. That’s the prerequisite that makes AI code generation genuinely useful rather than risky:
AI generates; you evaluate. You’re the network engineer who knows whether a NAPALM load_replace_candidate() is appropriate for this change, whether the Pydantic model captures all the edge cases, whether the TextFSM template will hold up against different software versions. AI generates plausible code quickly; you verify it’s correct before it runs anywhere near a production device.
Test everything. The discipline from Part 4 applies regardless of whether you wrote the code yourself or an AI wrote it: write tests, run them, read the code. The pytest discipline scales to AI-generated code exactly as it does to hand-written code.
Don’t copy-paste into production. This should be obvious, but it’s worth stating explicitly: AI-generated automation code should go through the same code review process as any other code. The speed advantage AI provides is in getting to a first draft faster — not in skipping review.
The Discipline That Keeps AI-Assisted Automation Safe
Running through everything in this post, three principles emerge:
1. Read-first, write-second. AI tools are most powerful when used to understand before acting. Ask AI to explain a config, review a diff, identify what a command’s output means — before asking it to generate code that changes network state.
2. Validate at every boundary. Every time data crosses a boundary — AI API response → Python dict, YAML inventory → Pydantic model, CLI output → parsed fields — validate it. Pydantic, assertions, explicit checks. Don’t trust any external data source, AI included.
3. Human in the loop for state-changing operations. Autonomous AI execution is appropriate for read-only operations. For anything that changes network state, design your tools to require explicit human confirmation. The two-step prepare/confirm pattern is the right model.
Wrapping Up the Series
Twelve posts. Here’s what you’ve built:
| Post | What you learned |
|---|---|
| Part 1 | Why Python, environment setup, AI as pair programmer |
| Part 2 | Core data types with network examples |
| Part 3 | Dicts, sets, comprehensions, exception handling |
| Part 4 | Functions, regex, modules, testing with pytest |
| Part 5 | Netmiko: SSH automation, send_command, send_config |
| Part 6 | Parsing CLI output: TextFSM, NTC-templates, AI-assisted |
| Part 7 | YAML, JSON, Pydantic validation |
| Part 8 | Jinja2: config generation from structured data |
| Part 9 | REST APIs: requests, Arista eAPI, FortiManager |
| Part 10 | NAPALM: vendor-agnostic getters, config replace, diff |
| Part 11 | Nornir: parallel execution, inventory, result handling |
| Part 12 | AI in automation: review, parsing, MCP, discipline |
The automation stack you now have — Pydantic-validated YAML inventory, Jinja2 config generation, Netmiko/NAPALM/REST collection, Nornir parallelism, and AI assistance for the gaps — is production-grade. These are the tools used in real network environments, not tutorial toys.
The GitHub repos linked throughout the series (ciscocmd1, web-traffic-generator, fgt-config-diff, pmtud-sweeper, rst-forensics) are all examples of what comes next: taking a specific networking problem, writing focused Python that solves it, and publishing it in a way other engineers can use and learn from.
The best next step is to pick a specific, annoying manual task you do regularly — checking OSPF neighbour state on a particular set of devices, collecting interface error counters before and after a change window, verifying BGP communities match intent — and automate it using the tools from this series. A script that runs in production and saves you ten minutes a week is worth a hundred tutorials.
The lab topology from Parts 5–11 works as a testbed for everything in this post. The MCP server section requires an AI assistant that supports MCP — Claude desktop works well. The Anthropic API examples require an API key from console.anthropic.com.
This is the final post in the Python for Network Engineers series. The full series index is on the Python guides page.