Expose Python scripts, Ansible playbooks, and OpenTofu plans as REST APIs using YAML-driven service definitions.
- GitHub stars
- 18
- Files
- 14
- Last commit
- Nov 5, 2026
- License
- GPL-3.0
- Allowed tools
- —
What does the iag skill do?
iag exposes Python scripts, Ansible playbooks, and OpenTofu plans as REST APIs using YAML-driven service definitions.
Summary generated from the skill's SKILL.md.
About the iag skill
Automating infrastructure tasks by turning scripts and playbooks into REST APIs is what iag enables for DevOps and network engineers. This skill allows you to expose Python scripts, Ansible playbooks, and OpenTofu plans as manageable services using YAML-driven definitions. Once defined, these services can be imported into the Itential Automation Gateway using the iagctl db import command and subsequently called from Itential workflows via the GatewayManager.
Users manage the entire lifecycle through the iagctl CLI, which supports importing service definitions, running services for testing, and managing secrets. The system supports various service types including python-script, ansible-playbook, opentofu-plan, and custom executables. It handles dependency management through requirements files and integrates with Git repositories to pull code. While it provides a structured way to bridge automation code with API-driven workflows, users must ensure decorator property names match argparse flags exactly and handle JSON parsing for stdout results.
What an agent can do with it
- 1Run Ansible playbooks via workflowsTrigger Ansible playbooks from Itential workflows to manage network devices.
- 2Manage infrastructure with OpenTofuExecute OpenTofu apply, plan, or destroy actions through defined service YAMLs.
- 3Test services from the command lineVerify that a newly imported service works correctly using the iagctl tool.
Written from the skill's SKILL.md. Every use case is checked against a quote from it.
How to install the iag skill
Each command copies the skill folder into the client's skills directory. Restart the client or start a new session to load it.Install the iag skill in Plugin
/plugin marketplace add itential/builder-skills /plugin install itential-builder@itential-builder
Install the iag skill in Claude Code
~/.claude/skills/iag/SKILL.md
$ npx degit itential/builder-skills/skills/iag ~/.claude/skills/iagInstall the iag skill in Claude Code (project)
.claude/skills/iag/SKILL.md
$ npx degit itential/builder-skills/skills/iag .claude/skills/iagInstall the iag skill in Codex
~/.agents/skills/iag/SKILL.md
$ npx degit itential/builder-skills/skills/iag ~/.agents/skills/iagInstall the iag skill in Gemini CLI
~/.gemini/skills/iag/SKILL.md
$ npx degit itential/builder-skills/skills/iag ~/.gemini/skills/iagInstall the iag skill in Claude.ai
npx degit itential/builder-skills/skills/iag iag && zip -r iag.zip iag # claude.ai → Customize → Skills → + → Create skill → Upload a skill → iag.zip
Automated checks
Ran Nov 14, 2026Full report →Passed our automated checks. That is not a security audit: read the scripts before you let an agent run them. How checks work · What to fix for more points
- Source repository linked and reachableitential/builder-skills/skills/iag is public and reachablegithub.com/itential/builder-skills/tree/HEAD/skills/iag ↗Pass
- Open source license (OSI)GPL-3.0spdx.org/licenses/GPL-3.0.html ↗Pass
- Commit or release in the last yearLast commit 2026-11-05github.com/itential/builder-skills/commits ↗Pass
- No download-and-run or credential theft in scripts1 script read; no download-and-run, remote eval, base64 execution or credential uploadPass
- No hidden instructions in SKILL.mdNo override phrases, safeguard switches, credential requests or hidden charactersgithub.com/itential/builder-skills/blob/HEAD/skills/iag/SKIL ↗Pass
- Pre-approved tools are scopedNo pre-approved tools; the agent asks before using onegithub.com/itential/builder-skills/blob/HEAD/skills/iag/SKIL ↗Pass
- SKILL.md follows the Agent Skills formatthe description does not say when to use the skill; SKILL.md is 1039 lines; the spec suggests under 500github.com/itential/builder-skills/blob/HEAD/skills/iag/SKIL ↗Finding
SKILL.md
View on GitHubIAG — Itential Automation Gateway
IAG exposes Python scripts, Ansible playbooks, and OpenTofu plans as REST APIs. Everything is defined in YAML, imported with iagctl db import.
Write YAML → iagctl db import → Services available → Workflows call them
Customization
Before using this skill, check custom/org/, custom/team/ and custom/dev/
in this skill's own folder. Read every .md file found — any folder may be
empty or absent. Apply them on top of everything below; where a file overrides a
specific rule here, follow the override. More specific wins: dev > team > org >
this document. No customization may weaken this skill's safety
rules or put credentials in committed files.
Bundled files: paths in this skill that start with assets/ or scripts/ are
relative to this skill's own folder. When you read one, or pass one to a shell
command (which runs from the user's working folder), use this skill's folder +
that relative path — e.g. <this skill's folder>/assets/helpers/create/create-workflow.json.
Gotchas
clusterIdmust match the IAG cluster config — discover withGET /gateway_manager/v1/gateways/paramsmaps to decorator schema — check withiagctl run service <type> <name> --useinventoryis""(empty string) when not targeting nodes, not[]ornull- OpenTofu services require
action: apply|plan|destroyin the service YAML, with field namesvars/var-files(see the OpenTofu example below for the full field list) runServiceresult is JSON-RPC wrapped — see "Result Shape — JSON-RPC Wrapper" below for the full unwrapping patternstdoutis always a string — even when a Python script prints valid JSON,result.stdoutis a string (e.g.,"{\"hostname\":\"Router1\"}"). You must parse it before referencing fields inside it. Use aparsetask (WorkFlowEngine) ortransformationto convert the JSON string to an object.req-filepath is relative toworking-directory— ifworking-directory: scripts, thenreq-file: requirements.txtlooks forscripts/requirements.txtinside the cloned repo, not the repo root$vardoesn't resolve insidenewVariableobjects when wiringrunServiceoutputs — seeassets/AGENTS.mdRule 8; use aquerytask instead--forceskips existing same-name resources unless forced, and overwrites secrets too — see "Adding Secrets" below for the full warning and the fix (keepsecrets:out of the top-level YAML entirely).- Decorators reject unknown params — every
--setkey must exist in the decorator schema - Decorator property names become argparse flags verbatim, including underscores — a property named
inventory_namebecomes--inventory_name, not--inventory-name. If your script's argparse flag uses a hyphen, IAG's--inventory_name valuecall fails with "unrecognized arguments". Name every argparse flag exactly like its decorator property. - Unset decorator properties arrive as an empty string, not the schema
default— IAG always passes every decorator property as a CLI flag, even ones the caller didn't--set. If unset, the value is"", not the schema's declareddefault. Don't usechoices=["true","false"]on boolean-style flags —""isn't in that list and argparse will reject it. Instead accept any string and treat anything but"true"as false. - No way to pin the Python interpreter version — services run under the host's default
python3(often 3.9 on older RHEL/Rocky). A dependency using PEP 604 syntax (str | None) withoutfrom __future__ import annotationswill crash in IAG's venv even if it worked locally. Pin dependency versions inrequirements.txtfor 3.9 compatibility. - Local secret storage requires one-time gateway setup —
iagctl create secret --value/--prompt-valuefails with "you must specify a encryption file" until[secrets].encrypt_key_fileis set ingateway.confand the gateway is restarted. Checkiagctl-client get secret-providersfirst — external providers (Vault, CyberArk, etc.) don't need this, but they can only reference secrets that already exist there, not create new ones. - Run
iagctl-clientas the same OS user the gateway server runs as (often not the SSH login user) when creating local secrets — the client reads the sameencrypt_key_filepath from config to encrypt the value before sending it, and a restrictive (e.g.0400) key file owned by the server's user will deny read access to any other OS user, failing with the same "permission denied" error even though the server itself can read it fine. - Ansible
network_clineedsparamiko+look_for_keys = False— addparamikotoruntime.req-file(requirements.txt), and inansible.cfgadd[paramiko_connection]\nlook_for_keys = False. Withoutlook_for_keys = False, password auth fails with "No existing session". Usecisco.iosxr.iosxr_command(oransible.netcommon.cli_command) for show commands — NOTansible.builtin.raw - OpenTofu CLI syntax differs —
iagctl run service opentofu-plan apply <name> --set key=value(theapply/destroysubcommand goes between the type and service name) - OpenTofu results include
state_file— outputs are instate_file.outputs, notresult.stdoutlike Python/Ansible - JSON playbook output comes from
ansible.cfg, notruntime.env— on Gateway5 5.5.2,ANSIBLE_STDOUT_CALLBACKinruntime.envleft the output as plain text (tested withjsonandansible.posix.json). Putstdout_callback = ansible.posix.jsonin anansible.cfgand reference it withruntime.config-file;result.stdoutis then the playbook's JSON (plays,stats) as a string - The gateway may have no Ansible at all — an
ansible-playbookservice gets Ansible from its ownruntime.req-file. Arequirements.txtlistingansible(the full package, notansible-core) bringsansible-playbookplus vendor collections such asarista.eos,cisco.iosandansible.netcommon; the first run installs it (~40 s), later runs reuse it runService'sinventoryreaches the service on stdin, not as an Ansible inventory file — Gateway5 passes the chosen Inventory Manager nodes as{"inventory_nodes": [{"name", "attributes", "tags"}, ...]}. Apython-scriptreads them withjson.load(sys.stdin). Anansible-playbookservice needs an inventory script inruntime.inventorythat turns them into hosts (node name → host, tags → groups,itential_host/itential_user/itential_password→ansible_host/ansible_user/ansible_password); with only a staticinventory.yamlthe run reports "No inventory was parsed". A one-hostinventory.yamltemplated from decorator params still works when the caller passes device details asparamsinstead- After a
forceimport, wait for the platform to see the new service — the platform keeps its own copy of each service; untilGET /gateway_manager/v1/servicesshows the service's newid(a few seconds), runs fail with "… found in the database has an Id that does not match what was provided with your request". Re-check the list; don't re-import
How It Works
- Write a YAML service file — defines repos, decorators, secrets, services
iagctl db import— loads into IAGiagctl run service— test from CLIGatewayManager.runService— call from Itential workflows
Always start from a helper template — do not build YAML from scratch. Read the matching example first:
- Python service →
assets/helpers/iag/example-python-service.yaml - Ansible service →
assets/helpers/iag/example-ansible-service.yaml - OpenTofu service →
assets/helpers/iag/example-opentofu-service.yaml - Multi-service chain →
assets/helpers/iag/example-multi-service-chain.yaml - Full schema reference →
assets/helpers/iag/service-file-schema.md
Authentication
| Mode | Auth | How |
|---|---|---|
| Local | None needed | iagctl talks to local IAG directly |
| Server/Client | Login required | iagctl login <username> → interactive password prompt |
| Itential workflows | Pre-configured | Platform admin sets up gateway. clusterId references it. |
The agent cannot run iagctl login — it requires an interactive terminal. If the engineer hasn't logged in yet, tell them:
"Run
iagctl login adminin your terminal and enter your password. Once done, I can continue."
Quick check — if this works, you're authenticated:
iagctl get services
Writing Service Files
YAML Structure
A service file has these top-level sections (all optional — include only what you need):
decorators: [] # Input schemas for services
repositories: [] # Git repos with code
services: [] # Python/Ansible/OpenTofu services
registries: [] # Package registries (PyPI, Galaxy)
secrets: [] # Credentials and keys
Service Types
| Type | Key fields | Runs |
|---|---|---|
python-script |
filename, runtime.env, runtime.req-file |
Python file from repo |
ansible-playbook |
playbooks, runtime.inventory, runtime.env |
Ansible playbook(s) from repo |
opentofu-plan |
action, vars, var-files, state-file |
OpenTofu apply/plan/destroy |
executable |
filename, arg-format |
Custom executable |
Python Script Services
Complete service YAML with all common fields:
decorators:
- name: my-service # should match service name
schema:
$id: my-service # should match service name
$schema: https://json-schema.org/draft/202012/schema
properties:
device_ip:
type: string
description: "Target device IP"
examples: ["10.0.0.1", "172.20.100.63"]
device_type:
type: string
description: "Netmiko device type"
enum: ["cisco_ios", "cisco_xr", "cisco_nxos"]
default: "cisco_ios"
interfaces:
type: string
description: "Comma-separated interface names"
required:
- device_ip
- interfaces
type: object
repositories:
- name: my-repo
url: https://github.com/org/repo.git
reference: main
services:
- name: my-service
type: python-script
description: Connects to device and returns interface health report
filename: main.py
working-directory: scripts # directory containing main.py in repo
repository: my-repo
decorator: my-service # links to decorator above
secrets: # injected as env vars at runtime
- name: device-username
type: env
target: DEVICE_USERNAME # script reads os.environ['DEVICE_USERNAME']
- name: device-password
type: env
target: DEVICE_PASSWORD
runtime:
req-file: requirements.txt # or pyproject.toml — installs dependencies
env: # extra environment variables
NETMIKO_TIMEOUT: "30"
Python script contract — how IAG runs your script:
- Inputs arrive as
--property_nameCLI args. Decorator schema property names become argparse flags. A property nameddevice_ipbecomes--device_ip. - Credentials arrive as env vars from the
secretsblock. Useos.environ.get('DEVICE_USERNAME'). - Behavior selection via
runtime.env— use this to make one script serve multiple services (see pattern below). - Output: JSON to stdout. Always
print(json.dumps(result)). Even on errors, return JSON with"success": false. - Exit code: 0 for any parseable result (success or handled error). 1 only for fatal setup failures (missing credentials).
Script template:
#!/usr/bin/env python3
import argparse
import json
import os
import sys
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--device_ip", required=True)
parser.add_argument("--device_type", default="cisco_ios")
parser.add_argument("--interfaces", required=True)
args = parser.parse_args()
username = os.environ.get("DEVICE_USERNAME")
password = os.environ.get("DEVICE_PASSWORD")
if not username or not password:
print(json.dumps({"success": False, "error": "DEVICE_USERNAME and DEVICE_PASSWORD env vars required"}))
sys.exit(1)
try:
result = {"success": True, "data": do_work(args, username, password)}
print(json.dumps(result))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
if __name__ == "__main__":
main()
One-file-multi-service pattern: Same Python file, different services with different runtime.env:
services:
- name: aws-ec2-add
type: python-script
filename: aws-ec2.py # same file
working-directory: aws-operations
repository: my-repo
decorator: aws-ec2-add
runtime:
env:
OPERATION: add # script checks os.environ.get('OPERATION')
OUTPUT_FORMAT: json
secrets:
- name: aws_access_key_id
type: env
target: AWS_ACCESS_KEY_ID
Define a second service the same way, just with a different name/decorator and OPERATION: delete in runtime.env — same file, same secrets, only the operation value changes.
The script checks env vars first, then falls back to argparse:
operation = os.environ.get('OPERATION') or args.op
Ansible Playbook Services
Complete service YAML — runtime block is critical for Ansible:
decorators:
- name: sros-config
schema:
$id: sros-config
$schema: https://json-schema.org/draft/202012/schema
properties:
sros_cli_commands:
type: array
items:
type: string
minItems: 1
description: "CLI commands to execute"
target_hosts:
type: string
description: "Target hosts or inventory groups"
default: "all"
required:
- sros_cli_commands
type: object
repositories:
- name: my-ansible-repo
url: git@github.com:org/ansible-playbooks.git
private-key-name: git-ssh-key
services:
- name: sros-config
type: ansible-playbook
description: Execute CLI commands on Nokia SROS devices
playbooks:
- sros_config.yml # one playbook per service (array but always single)
working-directory: sros_config # directory containing the playbook
repository: my-ansible-repo
decorator: sros-config
runtime:
inventory: # REQUIRED for Ansible — inventory file(s)
- inventory.yaml
config-file: ansible.cfg # set stdout_callback here for JSON output (see Gotchas)
env:
ANSIBLE_HOST_KEY_CHECKING: "false" # disable SSH host key checking
Ansible service with secrets (SSH key injection):
services:
- name: linux-patch-check
type: ansible-playbook
playbooks:
- patch_check.yml
working-directory: linux_patch_check
repository: my-ansible-repo
decorator: linux-patch-check
secrets:
- name: SELAB-PEM # secret name in IAG
type: env
target: SELAB-PEM # playbook reads with lookup('env', 'SELAB-PEM')
runtime:
inventory:
- inventory.yaml
env:
ANSIBLE_HOST_KEY_CHECKING: "false"
The playbook writes the injected key to a temp file:
- name: Write PEM to temp file
ansible.builtin.copy:
content: "{{ lookup('env', 'SELAB-PEM') }}"
dest: "/tmp/ssh_key.pem"
mode: '0600'
Multiple services sharing a working-directory — different playbooks in the same directory:
services:
- name: linux-patch-check
playbooks: [patch_check.yml]
working-directory: linux_patch_check # same directory
# ...
- name: linux-execute-patch
playbooks: [execute_patch.yml]
working-directory: linux_patch_check # same directory
# ...
- name: linux-mock-patch
playbooks: [mock_patch.yml]
working-directory: linux_patch_check # same directory
# ...
Ansible runtime options (all optional, in the runtime: block):
| Field | Purpose | Example |
|---|---|---|
inventory |
Inventory file(s) | ["inventory.yaml"] |
config-file |
ansible.cfg path | "ansible.cfg" |
env |
Environment variables | {ANSIBLE_HOST_KEY_CHECKING: "false"} |
req-file |
pip requirements or ansible-galaxy requirements.yml | "requirements.txt" or "requirements.yml" |
extra-vars |
Extra variables | ["env=prod"] |
extra-vars-file |
Variable files | ["vars.yml"] |
check |
Dry-run mode | false |
diff |
Show diffs | true |
forks |
Parallel processes | 10 |
tags |
Run only these tags | "webservers" |
limit |
Limit to hosts | ["host1"] |
Ansible network_cli for network devices (Cisco XR, IOS, NXOS, Nokia SROS):
Use network_cli connection with vendor modules (e.g., cisco.iosxr.iosxr_command, ansible.netcommon.cli_command). This is the recommended approach for network devices.
Required files in the working directory:
requirements.txt — pip dependencies for network_cli:
paramiko
ansible.cfg — must include look_for_keys = False for password auth:
[defaults]
host_key_checking = False
stdout_callback = ansible.posix.json
timeout = 30
[persistent_connection]
connect_timeout = 30
command_timeout = 30
[paramiko_connection]
look_for_keys = False
inventory.yaml — use Jinja2 refs to decorator schema properties:
all:
children:
xr_device:
hosts:
xr-router:
ansible_host: "{{ device_ip }}"
ansible_user: "{{ device_username }}"
ansible_password: "{{ device_password }}"
ansible_connection: network_cli
ansible_network_os: cisco.iosxr.iosxr
ansible_ssh_common_args: '-o StrictHostKeyChecking=no'
ansible_host_key_checking: false
ansible_paramiko_host_key_checking: false
health_check.yml — playbook using vendor module:
---
- name: Cisco XR Health Check
hosts: xr_device
gather_facts: false
tasks:
- name: Run show commands
cisco.iosxr.iosxr_command:
commands:
- show version
- show platform
- show ip interface brief
register: command_output
- name: Display results
ansible.builtin.debug:
msg:
device_ip: "{{ ansible_host }}"
show_version: "{{ command_output.stdout[0] }}"
show_platform: "{{ command_output.stdout[1] }}"
show_ip_interface_brief: "{{ command_output.stdout[2] }}"
services.yaml — wire it all together with runtime.req-file:
services:
- name: xr-health-check
type: ansible-playbook
playbooks:
- health_check.yml
working-directory: playbooks
repository: xr-health-check-repo
decorator: xr-health-check
runtime:
inventory:
- inventory.yaml
config-file: ansible.cfg
req-file: requirements.txt
env:
ANSIBLE_HOST_KEY_CHECKING: "false"
Key points (paramiko/look_for_keys gotcha already covered above):
ansible_network_osmust match the vendor collection (e.g.,cisco.iosxr.iosxr,sros)- Inventory uses
{{ var }}Jinja2 refs matching decorator schema property names runtime.req-filecan be a piprequirements.txtor ansible-galaxyrequirements.yml
OpenTofu Plan Services
Complete service YAML — note the correct field names:
decorators:
- name: azure-landing-zone
schema:
$id: azure-landing-zone
$schema: https://json-schema.org/draft/202012/schema
properties:
resource_group_name:
type: string
description: "Resource group name"
default: "lz-demo-rg"
vnet_address_space:
type: array
items:
type: string
pattern: "^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$"
default: ["10.0.0.0/16"]
required:
- resource_group_name
type: object
repositories:
- name: my-tofu-repo
url: git@github.com:org/opentofu.git
private-key-name: git-ssh-key
services:
- name: azure-landing-zone
type: opentofu-plan
description: Deploy Azure landing zone infrastructure
working-directory: infra/modules/landing-zone # directory with .tf files
repository: my-tofu-repo
decorator: azure-landing-zone
action: apply # REQUIRED: apply, plan, or destroy
vars: [] # optional: ["-var flags"] e.g. ["region=us-east-1"]
var-files: [] # optional: ["-var-file flags"] e.g. ["prod.tfvars"]
state-file: null # optional: custom state file path
Secrets for cloud credentials use the TF_VAR_ convention:
services:
- name: deploy-infra
type: opentofu-plan
working-directory: infra
repository: my-tofu-repo
decorator: deploy-infra
action: apply
vars: []
var-files: []
state-file: null
secrets:
- name: aws-access-key
type: env
target: TF_VAR_aws_access_key # OpenTofu reads TF_VAR_* as variables
- name: aws-secret-key
type: env
target: TF_VAR_aws_secret_key
Decorator params pass directly as OpenTofu variables — each property in the decorator schema becomes a variable available to your .tf files. Backend/provider config lives in the .tf files, not the service YAML.
Decorators — Input Validation
Every service should have a decorator. The $id should match the service name:
decorators:
- name: my-service
schema:
$id: my-service # match service name, not "root"
$schema: https://json-schema.org/draft/202012/schema
properties:
device_ip:
type: string
description: "Target device IP"
format:
type: string
enum: ["json", "table"] # restricted values
default: "json"
commands:
type: array # array with item validation
items:
type: string
minItems: 1
verbose:
type: string
enum: ["true", "false"] # booleans as strings (common pattern)
default: "false"
required:
- device_ip
type: object
additionalProperties: false # reject unknown params (recommended)
Adding Secrets
Best practice: Never put real secret values in YAML. Define secret references in the service, create actual secrets separately.
# In services.yaml — only references, no values
services:
- name: my-service
type: python-script
filename: main.py
working-directory: scripts
repository: my-repo
secrets: # injected as env vars at runtime
- name: api-token # secret name in IAG
type: env
target: API_TOKEN # script reads os.environ['API_TOKEN']
# Create secrets separately — never in the YAML file
iagctl create secret api-token --prompt-value
WARNING: --force import overwrites secrets too. If your YAML has a top-level secrets: section with placeholder values, --force will replace real secrets with placeholders. Keep the top-level secrets: section out of services.yaml entirely. Only define secret references inside each service's secrets: array.
Files in this skill
14iag skill questions
How do I install the iag skill in Claude Code?
Run npx degit itential/builder-skills/skills/iag ~/.claude/skills/iag. Claude Code loads skills from ~/.claude/skills (all projects) or .claude/skills (one project) and uses a skill when its description matches the task.
Which clients can use the iag skill?
Any client that reads the Agent Skills format: Claude Code, Codex, Gemini CLI and claude.ai (upload as a ZIP). Scripts in the skill run only where the client allows code execution.
How do I import services into IAG?
Services are defined in YAML files and imported using the command iagctl db import.
How are Python script inputs passed to the service?
Inputs arrive as CLI arguments where decorator schema property names become argparse flags (e.g., a property named device_ip becomes --device_ip).
How should Python scripts return data?
Scripts should always print JSON to stdout, even when errors occur, using a format like print(json.dumps(result)).
What is the command to test a service from the CLI?
You can test services using the iagctl run service command.