Ansible has an objective correctness test that no model can talk its way past. Here is how to build an agent around it.
Highlights
- An AI agent for Ansible playbooks is only trustworthy when generation is paired with automated validation, because a plausible playbook and a correct one look identical until something runs it.
- The failure language models make most often is reaching for
shellandcommandinstead of proper modules, which produces a playbook that works once and reports changes forever after. - Idempotence is the gate that catches exactly that, since running the same playbook twice must report zero changes on the second pass, and no amount of model confidence can fake that result.
- The validation ladder runs cheapest first: yamllint in milliseconds, syntax check in seconds,
ansible-lintin seconds, check mode against a real inventory, then Molecule for the truth. - Molecule's default sequence includes an explicit idempotence stage between converge and verify, which means the hardest check is already built for you.
- Deterministic gates beat prompt instructions, so reject
shellwithoutchanged_when, enforce fully qualified collection names, and refuse plaintext secrets in code rather than asking the model nicely. - The agent's output should be a pull request against a repository, never a playbook applied to real inventory, because generation speed is worth having and generation autonomy is not.
Everyone who has asked a language model for an Ansible playbook has received something that looks right. It has tasks, it has names, the YAML is valid, and it probably even runs. Then you run it a second time and it reports four changed tasks, because the model reached for the shell module where a proper module existed, and your supposedly declarative automation is now a script that mutates a server every time it executes. Practitioner write ups consistently describe AI generated playbooks as roughly eighty percent correct, with the missing twenty percent concentrated in idempotence guards, secret handling, and environment specific edge cases.
That eighty percent figure is the whole opportunity and the whole problem. Boilerplate generation genuinely saves time, and unreviewed generation genuinely breaks servers. What makes Ansible an unusually good target for an agent is that the missing twenty percent is largely machine detectable: ansible-lint catches deprecated patterns and missing qualifications, check mode catches undefined variables against a real inventory, and an idempotence run catches the exact shell module mistake described above. This tutorial builds the generation and validation loop around those gates.
What an AI agent for Ansible playbooks is
An AI agent for Ansible playbooks is a language model wrapped in an automated validation loop that converts a natural language request into playbook YAML, runs it through linting, syntax checking, dry run, and idempotence testing against a disposable environment, feeds each failure back to the model for repair, and produces a reviewed pull request rather than applying anything to real infrastructure.
The loop is what distinguishes this from pasting a prompt into a chat window. A model asked to fix its own output without evidence guesses, while a model handed the specific ansible-lint rule identifier it violated fixes the actual problem, usually on the first attempt.
Why idempotence is the perfect gate
This deserves its own section because it is the strongest verification signal available in any infrastructure as code agent, and Ansible hands it to you free.
Idempotence means running the same playbook twice produces no changes on the second run. It is not a style preference, it is the property that makes Ansible safe to run repeatedly, and it is trivially machine checkable: run the playbook, run it again, and assert that the second run reports changed=0.
Now consider what language models get wrong. Trained on blog posts, Stack Overflow answers, and quick fixes, they reach readily for ansible.builtin.shell and ansible.builtin.command because those appear constantly in that corpus. Both are non idempotent by default, since Ansible cannot know whether an arbitrary command changed anything, so it reports changed every single time unless you supply creates, removes, or changed_when. A playbook full of shell tasks passes syntax checking, passes most linting, runs successfully, and fails an idempotence test immediately.
That is a rare gift in agent design: an objective, automated test that catches the model's most characteristic mistake, with no human judgment required.
The validation ladder
Order matters, because each rung costs more than the last and rejecting early saves the expensive stages for candidates that deserve them.
| Rung | Command | Catches | Typical cost |
|---|---|---|---|
| YAML lint | yamllint playbook.yml |
Indentation, trailing spaces, malformed structure. | Milliseconds. |
| Syntax check | ansible-playbook playbook.yml --syntax-check |
Invalid Ansible constructs, unparseable tasks. | About a second. |
| Static lint | ansible-lint playbook.yml |
Deprecated patterns, missing FQCN, unnamed tasks, risky shell use. | Seconds. |
| Dry run | ansible-playbook playbook.yml --check --diff |
Undefined variables, missing hosts, wrong paths. | Seconds to minutes. |
| Converge | molecule converge |
Tasks that fail against a real system. | Minutes. |
| Idempotence | molecule idempotence |
Non idempotent tasks, the signature LLM failure. | Minutes. |
| Verify | molecule verify |
Whether the end state is what was actually asked for. | Minutes. |
Molecule's default test sequence already runs these in a sensible order, moving through syntax, create, prepare, converge, idempotence, side_effect, verify, and destroy. One thing it does not include is a lint step, so run ansible-lint yourself before invoking Molecule rather than assuming it is covered.
Step 1: The generation prompt that prevents most failures
Prompt engineering will not replace validation, but a good prompt reduces how many repair cycles you pay for.
SYSTEM_PROMPT = """You write Ansible playbooks that pass ansible-lint in its
production profile and are strictly idempotent.
Hard requirements:
1. Use fully qualified collection names for every module, such as
ansible.builtin.package rather than package.
2. Never use ansible.builtin.shell, command, or raw when a purpose built module
exists. Prefer package, service, file, copy, template, lineinfile, user.
3. If shell or command is genuinely unavoidable, you MUST supply creates,
removes, or changed_when so the task is idempotent.
4. Give every task a descriptive name starting with a capital letter.
5. Never write a plaintext password, key, or token. Reference a vaulted
variable and add no_log: true to any task that handles a secret.
6. Set state explicitly on every module that accepts it.
7. Use handlers for service restarts rather than restarting unconditionally.
Return only the playbook YAML, with no explanation and no code fences."""Requirement three is the one that pays for itself. Told merely to be idempotent, a model produces shell tasks it believes are harmless, whereas told exactly which fields make a shell task idempotent, it usually supplies them.
Step 2: Deterministic gates before the model gets another turn
Some checks should never depend on a model's cooperation. Parse the generated YAML and enforce policy in code, because a rule in a prompt is advice and a rule in a validator is a rule.
import re
import yaml
BANNED_BARE = {"ansible.builtin.shell", "ansible.builtin.command",
"ansible.builtin.raw", "shell", "command", "raw"}
IDEMPOTENCE_ESCAPES = {"creates", "removes", "changed_when"}
SECRET_HINT = re.compile(r"(password|secret|token|api[_-]?key|private_key)", re.I)
PLAINTEXT_SECRET = re.compile(r":\s*[\"']?[A-Za-z0-9!@#$%^&*+/=]{8,}[\"']?\s*$")
def gate(playbook_yaml: str) -> list[str]:
"""Return a list of violations. Empty list means the playbook may proceed."""
problems: list[str] = []
try:
plays = yaml.safe_load(playbook_yaml)
except yaml.YAMLError as exc:
return [f"invalid YAML: {exc}"]
if not isinstance(plays, list):
return ["playbook must be a list of plays"]
for play in plays:
for task in play.get("tasks", []) or []:
name = task.get("name", "<unnamed>")
if "name" not in task:
problems.append("task is missing a name")
modules = [k for k in task
if k not in {"name", "when", "become", "notify", "tags",
"register", "loop", "vars", "no_log",
"changed_when", "creates", "removes"}]
for module in modules:
# Gate 1: shell and friends need an idempotence escape hatch
if module in BANNED_BARE:
args = task.get(module) or {}
keys = set(args) if isinstance(args, dict) else set()
if not (keys & IDEMPOTENCE_ESCAPES) and not (set(task) & IDEMPOTENCE_ESCAPES):
problems.append(
f"'{name}' uses {module} with no creates, removes, "
f"or changed_when, so it cannot be idempotent")
# Gate 2: fully qualified collection names only
elif "." not in module and module not in {"block", "rescue", "always"}:
problems.append(f"'{name}' uses '{module}' instead of an FQCN")
# Gate 3: secrets must be vaulted and logged carefully
flat = yaml.safe_dump(task)
if SECRET_HINT.search(flat):
if not task.get("no_log"):
problems.append(f"'{name}' handles a secret without no_log: true")
for line in flat.splitlines():
if SECRET_HINT.search(line) and PLAINTEXT_SECRET.search(line) \
and "{{" not in line:
problems.append(f"'{name}' appears to contain a plaintext secret")
break
return problemsThree properties make this worth writing rather than trusting the prompt. It is deterministic, so the same playbook always produces the same verdict. It runs in milliseconds, so it can reject a bad candidate before you spin up a container. And its output is specific enough to feed straight back to the model as a repair instruction, which is what makes the loop converge.
Step 3: The validation runner
Wrap the toolchain so each rung reports cleanly and the loop can decide whether to continue.
import subprocess
from dataclasses import dataclass
@dataclass
class Result:
rung: str
passed: bool
output: str
def run(cmd: list[str], rung: str, timeout: int = 600) -> Result:
"""Run one validation command inside the working directory."""
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
combined = (proc.stdout + proc.stderr)[-4000:]
return Result(rung=rung, passed=proc.returncode == 0, output=combined)
def validate(path: str = "generated.yml") -> list[Result]:
"""Walk the ladder, stopping at the first failure to save time."""
ladder = [
(["yamllint", "-f", "parsable", path], "yamllint"),
(["ansible-playbook", path, "--syntax-check"], "syntax"),
(["ansible-lint", "--profile", "production", "-f", "pep8", path], "lint"),
(["ansible-playbook", path, "--check", "--diff",
"-i", "inventory/test"], "check-mode"),
(["molecule", "test", "-s", "default"], "molecule"),
]
results = []
for cmd, rung in ladder:
result = run(cmd, rung)
results.append(result)
if not result.passed:
break # no point converging a playbook that will not lint
return resultsUsing ansible-lint --profile production rather than the default matters more than it looks. The production profile enforces the stricter rules that separate a playbook that runs from a playbook you would put in a repository, including naming conventions and fully qualified module names, which is exactly the class of thing generated code tends to skip.
Step 4: The Molecule scenario that does the real work
Molecule is the rung that turns opinion into evidence, and the idempotence stage is why it is in this tutorial.
# molecule/default/molecule.yml
---
driver:
name: docker
platforms:
- name: ubuntu-test
image: ubuntu:24.04
pre_build_image: false
command: /lib/systemd/systemd # so service tasks behave realistically
privileged: true
cgroupns_mode: host
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
provisioner:
name: ansible
config_options:
defaults:
callbacks_enabled: profile_tasks
verifier:
name: ansible# molecule/default/verify.yml
---
- name: Verify the requested end state
hosts: all
become: true
gather_facts: true
tasks:
- name: Gather service facts
ansible.builtin.service_facts:
- name: Confirm nginx is enabled and running
ansible.builtin.assert:
that:
- ansible_facts.services['nginx.service'].state == 'running'
- ansible_facts.services['nginx.service'].status == 'enabled'
fail_msg: "nginx is not running and enabled as requested"
- name: Confirm the configured port is listening
ansible.builtin.wait_for:
port: 8080
timeout: 10Running the systemd init inside the container is a deliberate choice. Service tasks behave very differently without a real init system, and a playbook that appears to work in a plain container while failing on a real host is worse than no test, because it produces false confidence.
Practice the validation toolchain properly
Molecule, roles, and Vault are the substance of this pipeline, and they reward hands on time. The Ansible Advanced Course on KodeKloud covers roles, vaults, dynamic inventory, cloud integration, and Molecule testing through a real deployment project, which is the toolchain this agent automates rather than replaces.
Step 5: The repair loop
Now assemble it. The loop generates, gates, validates, and feeds specific failures back, with a hard attempt limit so a confused model cannot spin forever.
MAX_ATTEMPTS = 4
def build_playbook(request: str, llm) -> tuple[str | None, list[str]]:
"""Generate and validate until the playbook passes or attempts run out."""
transcript: list[str] = []
feedback = ""
for attempt in range(1, MAX_ATTEMPTS + 1):
prompt = f"Request: {request}"
if feedback:
prompt += (f"\n\nYour previous attempt failed these checks. "
f"Fix each one specifically:\n{feedback}")
candidate = llm(SYSTEM_PROMPT, prompt)
open("generated.yml", "w").write(candidate)
# Cheap deterministic gates first: milliseconds, no containers needed.
violations = gate(candidate)
if violations:
feedback = "\n".join(f"- {v}" for v in violations)
transcript.append(f"attempt {attempt}: {len(violations)} policy violations")
continue
# Then the real toolchain, cheapest rung first.
results = validate()
failed = [r for r in results if not r.passed]
if not failed:
transcript.append(f"attempt {attempt}: all rungs passed")
return candidate, transcript
feedback = f"The {failed[0].rung} stage failed:\n{failed[0].output}"
transcript.append(f"attempt {attempt}: failed at {failed[0].rung}")
return None, transcript # exhausted: hand the transcript to a humanNotice that failure returns the transcript rather than the best guess. An agent that surrenders its last broken attempt as though it succeeded is worse than one that reports honestly, because a human reviewing a labelled failure spends five minutes while a human debugging a silent one spends an hour.
Note also what the loop never does. It never runs ansible-playbook against real inventory, only against inventory/test in check mode and against disposable Molecule containers. Generation speed is worth having, and generation autonomy against production is not.
Step 6: Measure it against a fixed task set
Before trusting this on anything that matters, find out how often it actually succeeds. Build a fixed set of requests with known correct shapes and score the agent on first pass and eventual success.
| Request | What correctness requires | Common model failure |
|---|---|---|
| Install and enable nginx on port 8080 | package, template, handler, service with state and enabled | Restarting the service unconditionally rather than via a handler |
| Create a user with an SSH key | user module, authorized_key, no plaintext key material | Using shell with useradd, which is never idempotent |
| Deploy a config file from a template | template module with owner, group, and mode set | Omitting mode, so permissions drift between runs |
| Add a cron job for log rotation | cron module with a named job | Appending to crontab with shell, duplicating on every run |
| Harden SSH configuration | lineinfile or template with validate, plus a handler | Editing with sed through shell and skipping validation |
| Install a package from a URL | get_url with checksum, then package with a local path | get_url without checksum, so the task is unverified |
Score every run on three numbers and watch them over time: how often the first attempt passes everything, how often the loop eventually succeeds within its attempt limit, and which rung fails most. That third number is the useful one, because a persistent cluster of idempotence failures tells you the prompt needs the specific fix rather than the model needs replacing.
Practise this on a real Ansible control node
Every rung above is best learned by running it and watching what it rejects. The AI Assisted Ansible course on KodeKloud covers authoring, validating, and securing playbooks with AI tooling and linters, and the KodeKloud playgrounds give you an environment with a control node and managed nodes ready to break.
Where the agent helps and where it does not
| Task type | Agent fit | Why |
|---|---|---|
| Standard package, service, and config tasks | Strong | Extremely well represented in training data and fully covered by the validation ladder. |
| Boilerplate role scaffolding | Strong | Structure is conventional and lint enforces the conventions. |
| Converting shell scripts to playbooks | Good | Mechanical translation, though it needs the idempotence gate most of all. |
| Multi host orchestration with ordering | Weak | Requires knowledge of your topology that no prompt conveys reliably. |
| Anything touching production secrets | Weak | Vault structure and key custody are decisions rather than generation. |
| Custom modules and complex Jinja2 filters | Weak | Subtle, poorly represented in training data, and expensive to get wrong. |
Be candid about the right column when you show colleagues. An agent pitched as an Ansible expert loses trust the first time it mangles a multi host rollout, while one pitched as a fast, verified first draft for routine tasks keeps it.
Where to start
- Pin the toolchain first with
pip install ansible-dev-tools, then confirmansible-lint --profile productionruns clean against an existing playbook you trust. - Write the deterministic gate function and unit test it against a deliberately bad playbook full of bare shell tasks.
- Add generation with the system prompt above and run the cheap rungs only, so you can see the failure pattern before paying for containers.
- Stand up one Molecule scenario with systemd and a real verify file, since converge without verification only proves nothing crashed.
- Add the repair loop with a hard attempt limit and log which rung fails most often.
- Score the agent against the fixed task set and record first pass and eventual success rates before anyone relies on it.
- Wire the output to a pull request, never to an inventory, and keep human review on the merge.
Conclusion
The reason Ansible is such a good fit for a generation agent is not that language models are especially good at it. They are roughly eighty percent good at it, which on its own is a liability rather than an asset. What makes it work is that Ansible ships an objective test for the exact thing models get wrong, since a playbook that reaches for shell where a module existed will fail an idempotence run every time, no matter how confident the explanation accompanying it was.
So build the ladder before the agent. Deterministic gates for policy, lint for convention, check mode for the real inventory shape, and Molecule for the truth, with every failure fed back as a specific instruction rather than a vague complaint. Then let the agent write the boring eighty percent quickly, and keep the pull request review that catches the rest.
Ready to Build Real Ansible Depth?
An agent is only as useful as the engineer reviewing its pull requests, and reviewing Ansible well means knowing what idempotence, handlers, and Vault actually require. The Ansible Advanced Course on KodeKloud covers roles, vaults, dynamic inventory, and Molecule testing on a real project, AI Assisted Ansible covers combining AI tooling with linters to author and secure playbooks, and the KodeKloud playgrounds give you a control node to practise on. Start with one today.
FAQs
Q1: What do I need to know before building an Ansible generation agent?
You need working Ansible plus intermediate Python, and no machine learning background. On the Ansible side, the essentials are what idempotence means and why it matters, the difference between purpose built modules and shell, how handlers defer service restarts, how Vault encrypts variables, and how to read ansible-lint output including rule identifiers. Those are exactly the things you must be able to judge, because the agent's output is only reviewable by someone who could have written it. On the Python side, subprocess handling, YAML parsing, and dataclasses cover the implementation, since the loop itself is under two hundred lines. Familiarity with Docker helps for the Molecule scenarios. For structured practice, the Ansible Advanced Course on KodeKloud covers roles, Vault, and Molecule testing, and AI Assisted Ansible covers the AI plus linter workflow this tutorial automates.
Q2: Why do AI generated playbooks fail idempotence so often?
Because the training data is full of shell commands and the model has no way to know that a command changed anything. Language models learn Ansible from blog posts, Stack Overflow answers, and quick fixes, where ansible.builtin.shell and ansible.builtin.command appear constantly because they are the fastest way to demonstrate a point. Both modules are non idempotent by default, since Ansible cannot inspect an arbitrary command's effect, so it reports the task as changed on every run unless you supply creates, removes, or changed_when. The result is a playbook that runs successfully, passes syntax checking, and reports changes forever, which quietly breaks the guarantee that makes Ansible safe to run repeatedly. The fix is a two part gate: reject bare shell tasks in code before the model gets another attempt, and run an idempotence test that requires zero changes on a second converge. That second check is objective, so no amount of confident explanation can pass it.
Q3: How is this different from GitHub Copilot or Ansible Lightspeed?
Those tools generate, while this pipeline generates and then proves. Copilot and Red Hat Ansible Lightspeed with IBM watsonx Code Assistant both suggest playbook content inside your editor, which is genuinely useful for speed and Lightspeed is specifically tuned for Ansible, with availability through Ansible Automation Platform subscriptions and a limited free tier in the VS Code extension. What an editor assistant does not do is run your validation toolchain, interpret the failures, and iterate automatically, because it is designed for a human in the loop at every keystroke. This pipeline suits a different job: unattended batch generation of routine playbooks, enforcement of house policy through deterministic gates that no suggestion engine knows about, and a measured success rate you can report. The two are complementary rather than competing, and a sensible setup uses an editor assistant for interactive authoring and a validated pipeline for anything that lands in a repository through automation.
Q4: Should the agent ever run playbooks against real infrastructure?
No, and the boundary is worth enforcing in code rather than policy. The agent should run check mode against a test inventory and full converges against disposable Molecule containers, and that is the extent of its execution rights. Its final output is a pull request, so your existing review, CI, and change management apply unchanged, which means the agent accelerates the work without becoming a new privileged path to production. Two practical reinforcements make this stick. Give the agent's execution environment credentials that only reach test infrastructure, since a boundary backed by permissions holds when a boundary backed by intention does not. And keep the human on the merge, because reviewing a diff of generated YAML takes minutes while recovering from a bad playbook applied fleet wide takes considerably longer. If you later automate applying merged playbooks, that belongs to your normal delivery pipeline rather than to the agent.
Q5: How do I handle secrets in AI generated playbooks?
Never let the generation step see or produce them, and enforce that mechanically. Instruct the model to reference vaulted variables rather than values, then verify with a deterministic gate that scans generated output for anything resembling a plaintext password, token, or key and rejects it before validation continues. Add a second gate requiring no_log: true on any task whose fields mention secret shaped names, since Ansible will otherwise print variable values into logs when a task fails, which is precisely when people are pasting output into tickets and chat. On the storage side, keep secrets in Ansible Vault or an external secret manager and give the agent's environment only the test credentials it needs for Molecule, never production vault keys. Two things to avoid entirely: including real secrets in the prompt as an example, because prompts are frequently logged, and letting the agent generate the vault structure itself, since key custody and rotation are decisions your team should make explicitly.
Q6: How accurate is this in practice, and how do I measure it?
Expect a first pass rate well below what the demos suggest and an eventual success rate that makes the pipeline worthwhile, and measure both rather than trusting either. Build a fixed set of representative requests with known correct shapes, then track three numbers over time: the share of requests where the first generated playbook clears every rung, the share where the repair loop succeeds within its attempt limit, and the distribution of which rung fails most often. That third number is the one that improves your system, because a cluster of idempotence failures points at a prompt fix, a cluster of check mode failures points at inventory or variable context the agent lacks, and a cluster of lint failures usually means you should pin a stricter profile and let the loop learn against it. Re run the set whenever you change the prompt or the model, since a change that improves one task category often quietly degrades another, and without a fixed benchmark you will not notice.
Discussion