Skip to Content

Building an AI Agent That Writes and Validates Ansible Playbooks

Building an AI Agent That Writes and Validates Ansible Playbooks
Building an AI Agent That Writes Ansible Playbooks

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 shell and command instead 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-lint in 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 shell without changed_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.

RungCommandCatchesTypical 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 problems

Three 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 results

Using 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: 10

Running 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.

Course Β· Advanced

Ansible Advanced Course

Roles, Vault, dynamic inventory, cloud integration, and Molecule testing through a real deployment project. The toolchain this agent automates rather than replaces.

AnsibleMoleculeVault
Start the Ansible Advanced course →

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 human

Notice 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.

RequestWhat correctness requiresCommon 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.

Course Β· Hands on

AI Assisted Ansible

Author, validate, and secure playbooks by combining AI tooling with linters in VS Code. The closest course match to what this tutorial builds end to end.

AnsibleAI toolingLinting
Explore AI Assisted Ansible →

Where the agent helps and where it does not

Task typeAgent fitWhy
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

  1. Pin the toolchain first with pip install ansible-dev-tools, then confirm ansible-lint --profile production runs clean against an existing playbook you trust.
  2. Write the deterministic gate function and unit test it against a deliberately bad playbook full of bare shell tasks.
  3. Add generation with the system prompt above and run the cheap rungs only, so you can see the failure pattern before paying for containers.
  4. Stand up one Molecule scenario with systemd and a real verify file, since converge without verification only proves nothing crashed.
  5. Add the repair loop with a hard attempt limit and log which rung fails most often.
  6. Score the agent against the fixed task set and record first pass and eventual success rates before anyone relies on it.
  7. 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.

Playgrounds

KodeKloud Playgrounds

An Ansible control node with managed nodes, ready in seconds. Run the validation ladder, watch it reject a bad playbook, and break things freely.

AnsibleDevOpsSandbox
Launch a playground →

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.

Pramodh Kumar M Pramodh Kumar M

Subscribe to Newsletter

Join me on this exciting journey as we explore the boundless world of web design together.